Skip to content
Merged
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
47 changes: 29 additions & 18 deletions frontend/src/components/landing/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
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.1 } },
}

const itemVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
}

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

{/* Centered Logo Mark */}
<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' }}
<motion.div
variants={itemVariants}
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"
>
<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>
</div>
</motion.div>

{/* Status Pill */}
<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' }}
<motion.div
variants={itemVariants}
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-background-surface-alt border border-border-subtle mb-8"
>
<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 */}
<h1
className="text-[48px] sm:text-[56px] font-bold text-text-primary tracking-tight leading-[1.1] mb-6 slide-up"
style={{ animationDelay: '300ms' }}
<motion.h1
variants={itemVariants}
className="text-[48px] sm:text-[56px] font-bold text-text-primary tracking-tight leading-[1.1] mb-6"
>
<Trans
i18nKey="landing.hero.headline"
Expand All @@ -65,17 +76,17 @@ const Hero: React.FC = () => {
</motion.h1>

{/* Subtext */}
<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' }}
<motion.p
variants={itemVariants}
className="text-base sm:text-lg text-text-secondary max-w-[540px] mx-auto mb-10 leading-[1.6]"
>
{t('landing.hero.subtitle')}
</motion.p>

{/* CTAs */}
<div
className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto slide-up"
style={{ animationDelay: '500ms' }}
<motion.div
variants={itemVariants}
className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto"
>
<button
onClick={() => navigate('/tasks/new')}
Expand All @@ -92,8 +103,8 @@ const Hero: React.FC = () => {
<span>{t('landing.hero.browseAgents')}</span>
<ArrowRight size={18} className="group-hover:translate-x-0.5 transition-transform" />
</button>
</div>
</section>
</motion.div>
</motion.section>
)
}

Expand Down
64 changes: 64 additions & 0 deletions frontend/src/components/landing/LiveDemoSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import LiveDemoSection from './LiveDemoSection'
import type { NetworkStats } from '../../types/api'

const { getStats } = vi.hoisted(() => ({ getStats: vi.fn() }))

vi.mock('../../services/api', () => ({
getStats,
}))

const STATS: NetworkStats = {
totalAgents: 12,
totalTasks: 347,
totalXLMTransacted: 1250.75,
uptimePercent: 99.98,
}

describe('LiveDemoSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('renders the example workflow steps immediately, without waiting on the API', () => {
getStats.mockReturnValue(new Promise(() => {})) // never resolves
render(<LiveDemoSection />)

expect(screen.getByTestId('demo-prompt')).toBeInTheDocument()
expect(screen.getByTestId('demo-step-research')).toBeInTheDocument()
expect(screen.getByTestId('demo-step-risk')).toBeInTheDocument()
expect(screen.getByTestId('demo-step-report')).toBeInTheDocument()
})

it('shows a loading state while fetching live stats', () => {
getStats.mockReturnValue(new Promise(() => {}))
render(<LiveDemoSection />)

expect(screen.getByTestId('demo-stats-loading')).toBeInTheDocument()
})

it('renders real stats from the API once loaded', async () => {
getStats.mockResolvedValue(STATS)
render(<LiveDemoSection />)

await waitFor(() => {
expect(screen.getByTestId('demo-stats')).toBeInTheDocument()
})

expect(screen.getByText('347')).toBeInTheDocument()
expect(screen.getByText('12')).toBeInTheDocument()
expect(screen.getByText('100.0%')).toBeInTheDocument() // 99.98 rounded to 1 decimal
})

it('falls back to an unavailable message when the stats request fails', async () => {
getStats.mockRejectedValue(new Error('network error'))
render(<LiveDemoSection />)

await waitFor(() => {
expect(screen.queryByTestId('demo-stats-loading')).not.toBeInTheDocument()
})

expect(screen.queryByTestId('demo-stats')).not.toBeInTheDocument()
})
})
166 changes: 166 additions & 0 deletions frontend/src/components/landing/LiveDemoSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { motion } from 'framer-motion'
import { Search, ShieldAlert, FileText, CheckCircle2, Loader2 } from 'lucide-react'
import { getStats } from '../../services/api'
import { NetworkStats } from '../../types/api'
import { formatNumber } from '../../utils/format'

interface DemoStep {
key: string
icon: React.ReactNode
}

const demoSteps: DemoStep[] = [
{ key: 'research', icon: <Search size={16} className="text-[#60A5FA]" /> },
{ key: 'risk', icon: <ShieldAlert size={16} className="text-[#FBBF24]" /> },
{ key: 'report', icon: <FileText size={16} className="text-accent-purple" /> },
]

const containerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.4 } },
}

const stepVariants = {
hidden: { opacity: 0, x: -12 },
visible: { opacity: 1, x: 0, transition: { duration: 0.35 } },
}

const LiveDemoSection: React.FC = () => {
const { t, i18n } = useTranslation()
const [stats, setStats] = useState<NetworkStats | null>(null)
const [error, setError] = useState(false)

useEffect(() => {
let cancelled = false
getStats()
.then((data) => {
if (!cancelled) setStats(data)
})
.catch(() => {
if (!cancelled) setError(true)
})
return () => {
cancelled = true
}
}, [])

return (
<section className="px-4 max-w-[1000px] mx-auto pb-24">
<motion.div
className="text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.5 }}
>
<h2 className="text-[11px] font-bold text-text-secondary uppercase tracking-[0.2em] mb-2">
{t('landing.demo.title')}
</h2>
<p className="text-sm text-text-secondary/60 max-w-[440px] mx-auto">
{t('landing.demo.subtitle')}
</p>
</motion.div>

<motion.div
className="bg-background-surface border border-border-subtle rounded-2xl overflow-hidden shadow-lg grid grid-cols-1 md:grid-cols-[1.4fr_1fr]"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.5 }}
>
{/* Example workflow walkthrough */}
<motion.div
className="p-6 flex flex-col gap-3 border-b md:border-b-0 md:border-r border-border-subtle/50"
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-50px' }}
>
<span className="text-[10px] font-bold text-text-secondary uppercase tracking-wider mb-1">
{t('landing.demo.exampleLabel')}
</span>
<p className="text-sm text-text-primary font-medium mb-2" data-testid="demo-prompt">
{t('landing.demo.prompt')}
</p>

{demoSteps.map((step) => (
<motion.div
key={step.key}
variants={stepVariants}
className="flex items-center gap-3 bg-background-surface-alt border border-border-subtle rounded-xl px-4 py-3"
data-testid={`demo-step-${step.key}`}
>
<div className="w-8 h-8 rounded-lg bg-background-surface flex items-center justify-center shrink-0">
{step.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-text-primary">
{t(`landing.demo.steps.${step.key}.agent`)}
</p>
<p className="text-xs text-text-secondary truncate">
{t(`landing.demo.steps.${step.key}.result`)}
</p>
</div>
<CheckCircle2 size={16} className="text-accent-green shrink-0" />
</motion.div>
))}
</motion.div>

{/* Live network stats — real data from the API, not part of the fixed example. */}
<div className="p-6 flex flex-col justify-center gap-5 bg-background-surface-alt/40">
<span className="text-[10px] font-bold text-text-secondary uppercase tracking-wider">
{t('landing.demo.liveLabel')}
</span>

{error ? (
<p className="text-sm text-text-secondary">{t('landing.demo.unavailable')}</p>
) : !stats ? (
<div className="flex items-center gap-2 text-text-secondary" data-testid="demo-stats-loading">
<Loader2 size={16} className="animate-spin" />
<span className="text-sm">{t('landing.demo.loading')}</span>
</div>
) : (
<div className="grid grid-cols-2 gap-4" data-testid="demo-stats">
<div>
<span className="block text-[26px] font-bold tracking-tight text-accent-cyan">
{formatNumber(stats.totalTasks, i18n.language)}
</span>
<span className="text-[11px] font-medium text-text-secondary uppercase tracking-[0.06em]">
{t('landing.demo.tasksOrchestrated')}
</span>
</div>
<div>
<span className="block text-[26px] font-bold tracking-tight text-accent-purple">
{formatNumber(stats.totalAgents, i18n.language)}
</span>
<span className="text-[11px] font-medium text-text-secondary uppercase tracking-[0.06em]">
{t('landing.demo.activeAgents')}
</span>
</div>
<div>
<span className="block text-[26px] font-bold tracking-tight text-accent-green">
{formatNumber(stats.totalXLMTransacted, i18n.language)}
</span>
<span className="text-[11px] font-medium text-text-secondary uppercase tracking-[0.06em]">
{t('landing.demo.xlmPaid')}
</span>
</div>
<div>
<span className="block text-[26px] font-bold tracking-tight text-text-primary">
{stats.uptimePercent.toFixed(1)}%
</span>
<span className="text-[11px] font-medium text-text-secondary uppercase tracking-[0.06em]">
{t('landing.demo.uptime')}
</span>
</div>
</div>
)}
</div>
</motion.div>
</section>
)
}

export default LiveDemoSection
20 changes: 20 additions & 0 deletions frontend/src/components/landing/ValuePropsSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import ValuePropsSection from './ValuePropsSection'

describe('ValuePropsSection', () => {
it('renders a card for every value proposition', () => {
render(<ValuePropsSection />)

expect(screen.getByText('On-chain discovery')).toBeInTheDocument()
expect(screen.getByText('Autonomous orchestration')).toBeInTheDocument()
expect(screen.getByText('Instant Stellar payments')).toBeInTheDocument()
expect(screen.getByText('Composable workflows')).toBeInTheDocument()
})

it('renders the section heading and subtitle', () => {
render(<ValuePropsSection />)

expect(screen.getByText('Why ai-net')).toBeInTheDocument()
})
})
Loading
Loading