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
224 changes: 224 additions & 0 deletions harvest-finance/frontend/src/app/operators/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
'use client';

import React, { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Header } from '@/components/landing/Header';
import { Footer } from '@/components/landing/Footer';
import { Container, Section, Card, CardBody, CardHeader, Button, Badge, cn } from '@/components/ui';
import { CircularGauge } from '@/components/operator/CircularGauge';
import { ScoreBreakdown } from '@/components/operator/ScoreBreakdown';
import { ScoreHistoryChart } from '@/components/operator/ScoreHistoryChart';
import { operatorApi } from '@/lib/api/operator-client';
import { ArrowLeft, ArrowUpRight, Activity, Calendar } from 'lucide-react';
import type { OperatorReputation } from '@/types/operator';

const MOCK_REPUTATION: OperatorReputation = {
operatorId: '1',
operatorName: 'Harvest Alpha Fund',
overallScore: 87,
components: {
vaultPerformance: 92,
operatorTenure: 85,
governanceParticipation: 78,
securityIncidents: 95,
},
vaultHistory: [
{ vaultId: '1', vaultName: 'Stellar USDC Yield', asset: 'USDC', apy: '8.5', tvl: '$12.4M', status: 'active', startDate: '2024-03-15' },
{ vaultId: '2', vaultName: 'XLM Alpha Vault', asset: 'XLM', apy: '12.2', tvl: '$8.1M', status: 'active', startDate: '2024-06-01' },
{ vaultId: '3', vaultName: 'Eco-Farm Governance', asset: 'HRVST', apy: '24.5', tvl: '$4.2M', status: 'active', startDate: '2024-09-10' },
],
scoreHistory: Array.from({ length: 12 }, (_, i) => {
const d = new Date(2025, i, 1);
return { date: d.toISOString(), score: Math.round(75 + Math.sin(i * 0.5) * 10 + Math.random() * 5) };
}),
};

const statusVariant = (status: string) => {
switch (status) {
case 'active': return 'success';
case 'paused': return 'warning';
case 'closed': return 'error';
default: return 'default';
}
};

export default function OperatorProfilePage() {
const params = useParams();
const id = params.id as string;
const [showBreakdown, setShowBreakdown] = useState(false);

const { data: rep, isLoading } = useQuery<OperatorReputation>({
queryKey: ['operator-reputation', id],
queryFn: () => operatorApi.getReputation(id),
initialData: MOCK_REPUTATION,
});

if (isLoading) {
return (
<div className="min-h-screen bg-[#f4f8f0] dark:bg-[#0d1f12] flex items-center justify-center">
<div className="text-gray-500">Loading operator profile...</div>
</div>
);
}

return (
<div className="min-h-screen bg-[#f4f8f0] dark:bg-[#0d1f12] flex flex-col">
<Header />

<main className="flex-1 pt-24 pb-16">
<Section>
<Container size="lg">
<div className="mb-8">
<Link
href="/vaults"
className="inline-flex items-center gap-2 text-xs font-black uppercase tracking-widest text-gray-400 hover:text-harvest-green-600 transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Back to Vaults
</Link>
</div>

{/* Hero Section */}
<div className="grid lg:grid-cols-3 gap-8 mb-12">
<Card className="lg:col-span-1 flex flex-col items-center justify-center p-10 border border-gray-200 dark:border-white/5">
<CardBody className="flex flex-col items-center">
<button onClick={() => setShowBreakdown(true)} className="focus:outline-none">
<CircularGauge score={rep.overallScore} size={160} strokeWidth={10} />
</button>
<h1 className="mt-6 text-2xl font-black text-gray-900 dark:text-white tracking-tighter">
{rep.operatorName}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Operator ID: {rep.operatorId}
</p>
<div className="flex items-center gap-2 mt-4">
<Badge variant={rep.overallScore >= 80 ? 'success' : rep.overallScore >= 60 ? 'warning' : 'error'} size="sm">
{rep.overallScore >= 80 ? 'Trusted' : rep.overallScore >= 60 ? 'Moderate' : 'Caution'}
</Badge>
</div>
</CardBody>
</Card>

<Card className="lg:col-span-2 border border-gray-200 dark:border-white/5">
<CardBody className="p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-black text-gray-900 dark:text-white tracking-tighter">
Trust Score Overview
</h2>
<Button
variant="outline"
size="sm"
onClick={() => setShowBreakdown(!showBreakdown)}
className="rounded-xl text-[10px] font-black uppercase tracking-widest"
>
{showBreakdown ? 'Hide' : 'View'} Breakdown
</Button>
</div>
{showBreakdown && <ScoreBreakdown components={rep.components} />}
{!showBreakdown && (
<div className="py-8 text-center">
<p className="text-sm text-gray-400 dark:text-gray-500">
Click the score gauge or press &quot;View Breakdown&quot; to see detailed score components
</p>
</div>
)}
</CardBody>
</Card>
</div>

{/* Score History Chart */}
<Card className="mb-8 border border-gray-200 dark:border-white/5">
<CardHeader className="p-8 pb-0">
<div className="flex items-center gap-3">
<Activity className="w-5 h-5 text-harvest-green-500" />
<h2 className="text-lg font-black text-gray-900 dark:text-white tracking-tighter">
Score History
</h2>
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400">
12 Month Trend
</span>
</div>
</CardHeader>
<CardBody className="p-8 pt-4">
<ScoreHistoryChart data={rep.scoreHistory} height={250} />
</CardBody>
</Card>

{/* Vault History */}
<Card className="border border-gray-200 dark:border-white/5">
<CardHeader className="p-8 pb-0">
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-harvest-green-500" />
<h2 className="text-lg font-black text-gray-900 dark:text-white tracking-tighter">
Vault History
</h2>
</div>
</CardHeader>
<CardBody className="p-8 pt-4">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-400 border-b border-gray-100 dark:border-white/5">
<th className="pb-3 pr-4">Vault</th>
<th className="pb-3 pr-4">Asset</th>
<th className="pb-3 pr-4">APY</th>
<th className="pb-3 pr-4">TVL</th>
<th className="pb-3 pr-4">Status</th>
<th className="pb-3 pr-4">Start Date</th>
<th className="pb-3" />
</tr>
</thead>
<tbody>
{rep.vaultHistory.map((vault) => (
<tr
key={vault.vaultId}
className="border-b border-gray-50 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
>
<td className="py-4 pr-4">
<span className="text-sm font-bold text-gray-900 dark:text-zinc-100">
{vault.vaultName}
</span>
</td>
<td className="py-4 pr-4">
<span className="text-sm text-gray-600 dark:text-gray-400">{vault.asset}</span>
</td>
<td className="py-4 pr-4">
<span className="text-sm font-bold text-emerald-500">{vault.apy}%</span>
</td>
<td className="py-4 pr-4">
<span className="text-sm text-gray-600 dark:text-gray-400">{vault.tvl}</span>
</td>
<td className="py-4 pr-4">
<Badge variant={statusVariant(vault.status)} size="sm">
{vault.status}
</Badge>
</td>
<td className="py-4 pr-4">
<span className="text-sm text-gray-500">{vault.startDate}</span>
</td>
<td className="py-4">
<Link
href={`/strategies/${vault.vaultId}`}
className="inline-flex items-center gap-1 text-[10px] font-black uppercase tracking-widest text-harvest-green-600 hover:text-harvest-green-700 transition-colors"
>
View
<ArrowUpRight className="w-3 h-3" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardBody>
</Card>
</Container>
</Section>
</main>

<Footer />
</div>
);
}
37 changes: 35 additions & 2 deletions harvest-finance/frontend/src/components/dashboard/VaultCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import React from 'react';
import Link from 'next/link';
import { Card, CardHeader, CardBody, CardFooter, Button, Badge, Stack, Tooltip, cn } from '@/components/ui';
import { TrendingUp, Wallet, ArrowUpRight, ArrowDownLeft, Info, ShieldCheck, Activity } from 'lucide-react';
import { TrendingUp, Wallet, ArrowUpRight, ArrowDownLeft, Info, ShieldCheck, Activity, Award } from 'lucide-react';
import { StrategyType } from '@/types/vault';
import { formatI128 } from '@/lib/soroban-i128';
import { getTermTooltip } from '@/lib/defi-terms';
Expand All @@ -22,6 +22,24 @@ export interface VaultProps {
onDeposit: (vaultId: string) => void;
onWithdraw: (vaultId: string) => void;
shares?: number | string;
operatorId?: string;
operatorScore?: number;
}

function scoreColor(score: number): string {
if (score >= 80) return 'text-emerald-500';
if (score >= 60) return 'text-lime-500';
if (score >= 40) return 'text-yellow-500';
if (score >= 20) return 'text-orange-500';
return 'text-red-500';
}

function scoreBgColor(score: number): string {
if (score >= 80) return 'bg-emerald-500/10 border-emerald-500/20';
if (score >= 60) return 'bg-lime-500/10 border-lime-500/20';
if (score >= 40) return 'bg-yellow-500/10 border-yellow-500/20';
if (score >= 20) return 'bg-orange-500/10 border-orange-500/20';
return 'bg-red-500/10 border-red-500/20';
}

export const VaultCard: React.FC<VaultProps> = ({
Expand All @@ -37,6 +55,8 @@ export const VaultCard: React.FC<VaultProps> = ({
onDeposit,
onWithdraw,
shares,
operatorId,
operatorScore,
}) => {
return (
<Card className="group relative h-full overflow-hidden glass-panel glass-rim transition-all duration-500 hover:shadow-[0_20px_50px_rgba(34,197,94,0.15)] hover:-translate-y-1.5 border-emerald-500/10 dark:border-emerald-500/5">
Expand Down Expand Up @@ -151,7 +171,20 @@ export const VaultCard: React.FC<VaultProps> = ({
</div>
</Button>
</CardFooter>
<div className="px-6 pb-6 pt-0 text-center">
<div className="px-6 pb-6 pt-0 flex items-center justify-between">
{operatorId != null && operatorScore != null && (
<Link
href={`/operators/${operatorId}`}
className="inline-flex items-center gap-2 text-[10px] font-black text-gray-400 hover:text-harvest-green-600 transition-all uppercase tracking-[0.2em] group/link"
>
<div className={cn('flex items-center gap-1.5 px-2 py-1 rounded-lg border', scoreBgColor(operatorScore))}>
<Award className={cn('w-3 h-3', scoreColor(operatorScore))} />
<span className={cn('text-[10px] font-black', scoreColor(operatorScore))}>
{Math.round(operatorScore)}
</span>
</div>
</Link>
)}
<Link
href={`/strategies/${id}`}
className="inline-flex items-center justify-center gap-2 text-[10px] font-black text-gray-400 hover:text-harvest-green-600 transition-all uppercase tracking-[0.2em] group/link"
Expand Down
69 changes: 69 additions & 0 deletions harvest-finance/frontend/src/components/operator/CircularGauge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client';

import React from 'react';

interface CircularGaugeProps {
score: number;
size?: number;
strokeWidth?: number;
className?: string;
}

function scoreColor(score: number): string {
if (score >= 80) return '#22c55e';
if (score >= 60) return '#84cc16';
if (score >= 40) return '#eab308';
if (score >= 20) return '#f97316';
return '#ef4444';
}

export const CircularGauge: React.FC<CircularGaugeProps> = ({
score,
size = 120,
strokeWidth = 8,
className = '',
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (score / 100) * circumference;
const color = scoreColor(score);

return (
<div className={`relative inline-flex items-center justify-center ${className}`}>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-gray-200 dark:text-gray-700"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className="transition-all duration-700 ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span
className="text-2xl font-black tracking-tighter"
style={{ color }}
>
{Math.round(score)}
</span>
<span className="text-[8px] font-black uppercase tracking-widest text-gray-400 dark:text-gray-500">
Score
</span>
</div>
</div>
);
};
Loading