From cfdc04742c5339900a1316bc0b67ed4aab66b5af Mon Sep 17 00:00:00 2001 From: Joseph-1-Duro Date: Mon, 29 Jun 2026 22:10:17 +0100 Subject: [PATCH] feat: add operator reputation score UI with circular gauge, breakdown, and history chart --- .../frontend/src/app/operators/[id]/page.tsx | 224 ++++++++++++++++++ .../src/components/dashboard/VaultCard.tsx | 37 ++- .../src/components/operator/CircularGauge.tsx | 69 ++++++ .../components/operator/ScoreBreakdown.tsx | 97 ++++++++ .../components/operator/ScoreHistoryChart.tsx | 91 +++++++ .../frontend/src/lib/api/operator-client.ts | 9 + .../frontend/src/types/operator.ts | 28 +++ 7 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 harvest-finance/frontend/src/app/operators/[id]/page.tsx create mode 100644 harvest-finance/frontend/src/components/operator/CircularGauge.tsx create mode 100644 harvest-finance/frontend/src/components/operator/ScoreBreakdown.tsx create mode 100644 harvest-finance/frontend/src/components/operator/ScoreHistoryChart.tsx create mode 100644 harvest-finance/frontend/src/lib/api/operator-client.ts create mode 100644 harvest-finance/frontend/src/types/operator.ts diff --git a/harvest-finance/frontend/src/app/operators/[id]/page.tsx b/harvest-finance/frontend/src/app/operators/[id]/page.tsx new file mode 100644 index 000000000..ee2102a17 --- /dev/null +++ b/harvest-finance/frontend/src/app/operators/[id]/page.tsx @@ -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({ + queryKey: ['operator-reputation', id], + queryFn: () => operatorApi.getReputation(id), + initialData: MOCK_REPUTATION, + }); + + if (isLoading) { + return ( +
+
Loading operator profile...
+
+ ); + } + + return ( +
+
+ +
+
+ +
+ + + Back to Vaults + +
+ + {/* Hero Section */} +
+ + + +

+ {rep.operatorName} +

+

+ Operator ID: {rep.operatorId} +

+
+ = 80 ? 'success' : rep.overallScore >= 60 ? 'warning' : 'error'} size="sm"> + {rep.overallScore >= 80 ? 'Trusted' : rep.overallScore >= 60 ? 'Moderate' : 'Caution'} + +
+
+
+ + + +
+

+ Trust Score Overview +

+ +
+ {showBreakdown && } + {!showBreakdown && ( +
+

+ Click the score gauge or press "View Breakdown" to see detailed score components +

+
+ )} +
+
+
+ + {/* Score History Chart */} + + +
+ +

+ Score History +

+ + 12 Month Trend + +
+
+ + + +
+ + {/* Vault History */} + + +
+ +

+ Vault History +

+
+
+ +
+ + + + + + + + + + + + + {rep.vaultHistory.map((vault) => ( + + + + + + + + + + ))} + +
VaultAssetAPYTVLStatusStart Date +
+ + {vault.vaultName} + + + {vault.asset} + + {vault.apy}% + + {vault.tvl} + + + {vault.status} + + + {vault.startDate} + + + View + + +
+
+
+
+
+
+
+ +
+
+ ); +} diff --git a/harvest-finance/frontend/src/components/dashboard/VaultCard.tsx b/harvest-finance/frontend/src/components/dashboard/VaultCard.tsx index cdd54ded8..4eb6c4bf3 100644 --- a/harvest-finance/frontend/src/components/dashboard/VaultCard.tsx +++ b/harvest-finance/frontend/src/components/dashboard/VaultCard.tsx @@ -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'; @@ -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 = ({ @@ -37,6 +55,8 @@ export const VaultCard: React.FC = ({ onDeposit, onWithdraw, shares, + operatorId, + operatorScore, }) => { return ( @@ -151,7 +171,20 @@ export const VaultCard: React.FC = ({ -
+
+ {operatorId != null && operatorScore != null && ( + +
+ + + {Math.round(operatorScore)} + +
+ + )} = 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 = ({ + 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 ( +
+ + + + +
+ + {Math.round(score)} + + + Score + +
+
+ ); +}; diff --git a/harvest-finance/frontend/src/components/operator/ScoreBreakdown.tsx b/harvest-finance/frontend/src/components/operator/ScoreBreakdown.tsx new file mode 100644 index 000000000..c1314ea66 --- /dev/null +++ b/harvest-finance/frontend/src/components/operator/ScoreBreakdown.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React from 'react'; +import { Card, CardBody, cn } from '@/components/ui'; +import { TrendingUp, Clock, Vote, ShieldAlert } from 'lucide-react'; + +interface ScoreBreakdownProps { + components: { + vaultPerformance: number; + operatorTenure: number; + governanceParticipation: number; + securityIncidents: number; + }; +} + +const COMPONENT_META = [ + { + key: 'vaultPerformance' as const, + label: 'Vault Performance', + icon: TrendingUp, + description: 'Historical yield consistency and ROI', + }, + { + key: 'operatorTenure' as const, + label: 'Operator Tenure', + icon: Clock, + description: 'Length and consistency of operation', + }, + { + key: 'governanceParticipation' as const, + label: 'Governance', + icon: Vote, + description: 'Community governance engagement', + }, + { + key: 'securityIncidents' as const, + label: 'Security Record', + icon: ShieldAlert, + description: 'Absence of security incidents', + }, +]; + +function componentColor(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 barColor(score: number): string { + if (score >= 80) return 'bg-emerald-500'; + if (score >= 60) return 'bg-lime-500'; + if (score >= 40) return 'bg-yellow-500'; + if (score >= 20) return 'bg-orange-500'; + return 'bg-red-500'; +} + +export const ScoreBreakdown: React.FC = ({ components }) => { + return ( +
+ {COMPONENT_META.map(({ key, label, icon: Icon, description }) => { + const score = components[key]; + return ( + + +
+
+ +
+
+
+ + {label} + + + {Math.round(score)} + +
+
+
+
+

+ {description} +

+
+
+ + + ); + })} +
+ ); +}; diff --git a/harvest-finance/frontend/src/components/operator/ScoreHistoryChart.tsx b/harvest-finance/frontend/src/components/operator/ScoreHistoryChart.tsx new file mode 100644 index 000000000..ac98e98b3 --- /dev/null +++ b/harvest-finance/frontend/src/components/operator/ScoreHistoryChart.tsx @@ -0,0 +1,91 @@ +'use client'; + +import React from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { format } from 'date-fns'; +import type { ScoreHistoryPoint } from '@/types/operator'; + +interface ScoreHistoryChartProps { + data: ScoreHistoryPoint[]; + height?: number; +} + +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'; +} + +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + const value = payload[0].value; + return ( +
+

{label}

+

+ Score: {Math.round(value)} +

+
+ ); + } + return null; +}; + +export const ScoreHistoryChart: React.FC = ({ + data, + height = 250, +}) => { + const chartData = data.map((point) => ({ + date: format(new Date(point.date), 'MMM yy'), + score: point.score, + timestamp: new Date(point.date).getTime(), + })); + + return ( + + + + + + + + + + + `${v}`} + /> + } /> + + + + ); +}; diff --git a/harvest-finance/frontend/src/lib/api/operator-client.ts b/harvest-finance/frontend/src/lib/api/operator-client.ts new file mode 100644 index 000000000..c3ac50c49 --- /dev/null +++ b/harvest-finance/frontend/src/lib/api/operator-client.ts @@ -0,0 +1,9 @@ +import apiClient from '../api-client'; +import type { OperatorReputation } from '@/types/operator'; + +export const operatorApi = { + getReputation: async (operatorId: string): Promise => { + const response = await apiClient.get(`/api/v1/operators/${operatorId}/reputation`); + return response.data; + }, +}; diff --git a/harvest-finance/frontend/src/types/operator.ts b/harvest-finance/frontend/src/types/operator.ts new file mode 100644 index 000000000..344fe5f8a --- /dev/null +++ b/harvest-finance/frontend/src/types/operator.ts @@ -0,0 +1,28 @@ +export interface OperatorReputation { + operatorId: string; + operatorName: string; + overallScore: number; + components: { + vaultPerformance: number; + operatorTenure: number; + governanceParticipation: number; + securityIncidents: number; + }; + vaultHistory: VaultHistoryEntry[]; + scoreHistory: ScoreHistoryPoint[]; +} + +export interface VaultHistoryEntry { + vaultId: string; + vaultName: string; + asset: string; + apy: string; + tvl: string; + status: 'active' | 'paused' | 'closed'; + startDate: string; +} + +export interface ScoreHistoryPoint { + date: string; + score: number; +}