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
7 changes: 7 additions & 0 deletions frontend/src/components/agents/AgentDetailModal.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,10 @@
grid-template-columns: 1fr;
}
}

.chartsContainer {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 0.5rem;
}
19 changes: 19 additions & 0 deletions frontend/src/components/agents/AgentDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { useTranslation } from 'react-i18next'
import { ExternalLink, X } from 'lucide-react'
import type { AgentRecord } from '../../types/api'
import { ReputationStars } from './ReputationStars'
import { useAgentReputation } from '../../hooks/useAgentReputation'
import { AgentReputationRadar } from './AgentReputationRadar'
import { AgentReputationTrend } from './AgentReputationTrend'
import styles from './AgentDetailModal.module.css'

const STELLAR_EXPLORER = 'https://stellar.expert/explorer/testnet'
Expand Down Expand Up @@ -123,6 +126,22 @@ export function AgentDetailModal({ agent, onClose }: AgentDetailModalProps) {
)}
</dd>
</div>

<div className={styles.fieldWide}>
<dt>Reputation Details</dt>
<dd>
{reputationLoading ? (
<div>Loading charts...</div>
) : reputationData ? (
<div className={styles.chartsContainer}>
<AgentReputationRadar dimensions={reputationData.dimensions} />
<AgentReputationTrend history={reputationData.history} />
</div>
) : (
<div>No detailed reputation data available</div>
)}
</dd>
</div>
</dl>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
34 changes: 34 additions & 0 deletions frontend/src/components/agents/AgentReputationRadar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { render } from '@testing-library/react';
import { AgentReputationRadar } from './AgentReputationRadar';
import { vi, describe, it, expect } from 'vitest';

// Mock recharts because ResponsiveContainer doesn't work well in JSDOM
vi.mock('recharts', async () => {
const OriginalRecharts = await vi.importActual<any>('recharts');
return {
...OriginalRecharts,
ResponsiveContainer: ({ children }: any) => (
<div style={{ width: '100%', height: 250 }}>{children}</div>
),
};
});

describe('AgentReputationRadar', () => {
it('renders the radar chart with given dimensions', () => {
const dimensions = {
quality: 90,
speed: 85,
reliability: 95,
cost: 80,
};

const { container } = render(<AgentReputationRadar dimensions={dimensions} />);

// Check if the container is rendered
expect(container.firstChild).toBeInTheDocument();

// In a real JSDOM environment with SVG, we could check for specific SVG elements.
// For this test, we ensure it renders without crashing and contains the ResponsiveContainer div.
expect(container.querySelector('.recharts-wrapper')).toBeInTheDocument();
});
});
31 changes: 31 additions & 0 deletions frontend/src/components/agents/AgentReputationRadar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer, Tooltip } from 'recharts';
import type { ReputationDimensions } from '../../types/agent';
import styles from './AgentReputationRadar.module.css';

interface AgentReputationRadarProps {
dimensions: ReputationDimensions;
}

export const AgentReputationRadar: React.FC<AgentReputationRadarProps> = ({ dimensions }) => {
const data = [
{ subject: 'Quality', A: dimensions.quality, fullMark: 100 },
{ subject: 'Speed', A: dimensions.speed, fullMark: 100 },
{ subject: 'Reliability', A: dimensions.reliability, fullMark: 100 },
{ subject: 'Cost', A: dimensions.cost, fullMark: 100 },
];

return (
<div className={styles.container}>
<ResponsiveContainer width="100%" height={250}>
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={data}>
<PolarGrid />
<PolarAngleAxis dataKey="subject" tick={{ fill: '#8884d8', fontSize: 12 }} />
<PolarRadiusAxis angle={30} domain={[0, 100]} tick={false} />
<Radar name="Reputation" dataKey="A" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.container {
width: 100%;
margin-top: 1rem;
}
34 changes: 34 additions & 0 deletions frontend/src/components/agents/AgentReputationTrend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Brush } from 'recharts';
import type { ReputationHistory } from '../../types/agent';
import styles from './AgentReputationTrend.module.css';

interface AgentReputationTrendProps {
history: ReputationHistory[];
}

export const AgentReputationTrend: React.FC<AgentReputationTrendProps> = ({ history }) => {
// Format dates for display
const data = history.map(item => {
const d = new Date(item.date);
return {
...item,
displayDate: `${d.getMonth() + 1}/${d.getDate()}`
};
});

return (
<div className={styles.container}>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="displayDate" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line type="monotone" dataKey="score" stroke="#8884d8" strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} />
<Brush dataKey="displayDate" height={30} stroke="#8884d8" />
</LineChart>
</ResponsiveContainer>
</div>
);
};
54 changes: 20 additions & 34 deletions frontend/src/components/landing/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
import React from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Sparkles, ArrowRight } from 'lucide-react'
import { useParticles } from '../../hooks/useParticles'
import { useTypingAnimation } from '../../hooks/useTypingAnimation'
import styles from './Hero.module.css'

const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.12, delayChildren: 0.2 },
},
} as const

const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: 'easeOut' as const } },
} as const

const Hero: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
Expand All @@ -45,29 +31,29 @@ const Hero: React.FC = () => {
</div>

{/* Centered Logo Mark */}
<motion.div
className="w-[72px] h-[72px] rounded-2xl bg-background-surface border border-border-subtle flex items-center justify-center mb-8 shadow-xl relative overflow-hidden"
variants={itemVariants}
<div
className="w-[72px] h-[72px] rounded-2xl bg-background-surface border border-border-subtle flex items-center justify-center mb-8 shadow-xl relative overflow-hidden slide-up"
style={{ animationDelay: '100ms' }}
>
<div className="absolute inset-0 bg-gradient-primary opacity-20 blur-xl" />
<div className="w-[42px] h-[42px] rounded-xl bg-gradient-primary flex items-center justify-center font-bold text-white text-2xl relative z-10 shadow-[0_0_20px_rgba(56,189,248,0.4)]">
a
</div>
</motion.div>
</div>

{/* Status Pill */}
<motion.div
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-background-surface-alt border border-border-subtle mb-8"
variants={itemVariants}
<div
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-background-surface-alt border border-border-subtle mb-8 slide-up"
style={{ animationDelay: '200ms' }}
>
<div className="w-1.5 h-1.5 rounded-full bg-accent-green shadow-[0_0_8px_rgba(52,211,153,0.6)] animate-pulse" />
<span className="text-xs font-semibold text-accent-green tracking-wide">{t('landing.hero.badge')}</span>
</motion.div>

{/* Headline */}
<motion.h1
className="text-[48px] sm:text-[56px] font-bold text-text-primary tracking-tight leading-[1.1] mb-6"
variants={itemVariants}
<h1
className="text-[48px] sm:text-[56px] font-bold text-text-primary tracking-tight leading-[1.1] mb-6 slide-up"
style={{ animationDelay: '300ms' }}
>
<Trans
i18nKey="landing.hero.headline"
Expand All @@ -79,35 +65,35 @@ const Hero: React.FC = () => {
</motion.h1>

{/* Subtext */}
<motion.p
className="text-base sm:text-lg text-text-secondary max-w-[540px] mx-auto mb-10 leading-[1.6]"
variants={itemVariants}
<p
className="text-base sm:text-lg text-text-secondary max-w-[540px] mx-auto mb-10 leading-[1.6] slide-up"
style={{ animationDelay: '400ms' }}
>
{t('landing.hero.subtitle')}
</motion.p>

{/* CTAs */}
<motion.div
className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto"
variants={itemVariants}
<div
className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto slide-up"
style={{ animationDelay: '500ms' }}
>
<button
onClick={() => navigate('/tasks/new')}
className="group w-full sm:w-auto flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl bg-gradient-primary text-white font-semibold shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:shadow-[0_0_35px_rgba(139,92,246,0.5)] hover:scale-[1.02] active:scale-[0.98] transition-all"
className="group w-full sm:w-auto flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl bg-gradient-primary text-white font-semibold shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:shadow-[0_0_35px_rgba(139,92,246,0.5)] transition-all hover-scale focus-ring"
>
<Sparkles size={18} className="group-hover:rotate-12 transition-transform" />
<span>{t('landing.hero.startTask')}</span>
</button>

<button
onClick={() => navigate('/agents')}
className="group w-full sm:w-auto flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl bg-background-surface-alt border border-border-subtle text-text-secondary font-medium hover:text-text-primary hover:bg-background-surface hover:border-border-subtle/50 active:scale-[0.98] transition-all"
className="group w-full sm:w-auto flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl bg-background-surface-alt border border-border-subtle text-text-secondary font-medium hover:text-text-primary hover:bg-background-surface hover:border-border-subtle/50 transition-all hover-scale focus-ring"
>
<span>{t('landing.hero.browseAgents')}</span>
<ArrowRight size={18} className="group-hover:translate-x-0.5 transition-transform" />
</button>
</motion.div>
</motion.section>
</div>
</section>
)
}

Expand Down
41 changes: 41 additions & 0 deletions frontend/src/hooks/useAgentReputation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useState, useEffect } from 'react';
import { getAgentReputation } from '../services/api';
import type { AgentReputation } from '../types/agent';

export function useAgentReputation(agentId: string) {
const [data, setData] = useState<AgentReputation | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!agentId) return;

let mounted = true;
const fetchReputation = async () => {
setLoading(true);
setError(null);
try {
const result = await getAgentReputation(agentId);
if (mounted) {
setData(result);
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : 'Failed to fetch reputation data');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};

fetchReputation();

return () => {
mounted = false;
};
}, [agentId]);

return { data, loading, error };
}
4 changes: 4 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,7 @@ export const getRecentTasks = async (walletAddress: string): Promise<TaskRespons
export const getAgents = async (): Promise<AgentRecord[]> => {
return apiClient.get<AgentRecord[]>('/api/agents');
};

export const getAgentReputation = async (id: string): Promise<import('../types/agent').AgentReputation> => {
return apiClient.get<import('../types/agent').AgentReputation>(`/api/agents/${id}/reputation`);
};
70 changes: 70 additions & 0 deletions frontend/src/styles/animations.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.fade-in {
animation: fadeIn 0.3s ease forwards;
}

.slide-up {
animation: slideUp 0.3s ease forwards;
}

.scale-in {
animation: scaleIn 0.3s ease forwards;
}

.pulse {
animation: pulse 2s infinite ease-in-out;
}

@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}

@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}

@media (prefers-reduced-motion: reduce) {
.fade-in,
.slide-up,
.scale-in,
.pulse {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
animation-delay: 0.01ms !important;
}
}
Loading
Loading