From 83022e0eafc29654c5f74a54696aaa048f614108 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Sun, 30 Aug 2026 09:51:23 +0100 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20landing=20page=20refresh=20?= =?UTF-8?q?=E2=80=94=20value=20props=20grid=20and=20live=20demo=20(#356)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new sections between the stats bar and the specialist-agents grid: - ValuePropsSection: a 4-card grid (on-chain discovery, autonomous orchestration, instant Stellar payments, composable workflows), each with an icon, animating in on scroll (framer-motion whileInView, staggered). - LiveDemoSection: pairs a fixed example workflow walkthrough (the market-entry-report scenario already described in the README's demo section, presented as a staggered Research -> Risk -> Report reveal) with a "Live Network Activity" panel showing REAL numbers fetched from GET /api/stats (total tasks, active agents, XLM paid out, uptime) — the only public, no-wallet-required endpoint available (getRecentTasks needs a wallet address, so isn't usable for an anonymous landing page visitor). Loading and error states included. Both new sections plus the existing StatsBar/SpecialistAgentsSection animate on scroll via framer-motion's whileInView; Hero animates in on load (it's already in the viewport on page load, so whileInView isn't the right trigger there — it uses a staggered `animate="visible"` instead). ## Necessary prerequisite: Hero.tsx had mismatched JSX tags and didn't compile Hero.tsx opened ``/``/`

`/`

` but closed several of them with the WRONG tag (``, `` closing a plain `

`, `` closing a plain `

`, etc.) — a different corruption pattern than the "two versions concatenated" bug found across backend/ this session (see Epta-Node/ai-net#443/#460/#461/ #463), but equally blocking: the file was invalid JSX and neither Hero.test.tsx nor the landing page itself could ever have compiled as committed. Fixed by making every motion.* tag consistent (open and close both `motion.*` or both plain) and importing `motion`/defining `containerVariants`, which were also missing. While in there, converted the hero's per-element `animationDelay` + CSS `slide-up` class approach to framer-motion's `staggerChildren`, which is what the rest of the landing page already uses. Also added an `IntersectionObserver` mock to `vitest.setup.ts` — jsdom doesn't provide one, and framer-motion's `whileInView` (used throughout this page, including the pre-existing StatsBar/SpecialistAgentsSection) needs it. No landing-page test had ever actually mounted a `whileInView` component before (Hero.tsx didn't compile, and StatsBar/ SpecialistAgentsSection have no test files), so this gap was never hit until now. ## Acceptance Criteria - [x] Hero and sections animate on scroll - [x] Demo section shows a real orchestrated workflow result from API — via the live GET /api/stats panel; the step-by-step walkthrough itself is a labeled, fixed example (there's no public "get a random real task" endpoint to pull a literal historical run from without requiring a connected wallet), paired with real aggregate numbers from actual completed workflows. ## Test plan npx vitest run src/components/landing/ — 9/9 passing (3 Hero, 2 ValuePropsSection, 4 LiveDemoSection: immediate render of the example steps without waiting on the API, loading state, real stats rendered once loaded, graceful fallback on API failure). `npx tsc --noEmit` reports zero errors in any file this PR touches. It does surface the same JSX-tag-mismatch corruption pattern in three unrelated pre-existing files (App.tsx, components/wallet/SendXLMForm.tsx, context/ToastContext.tsx) — out of scope here, flagging separately. Closes #356 --- frontend/src/components/landing/Hero.tsx | 47 +++-- .../landing/LiveDemoSection.test.tsx | 64 +++++++ .../components/landing/LiveDemoSection.tsx | 166 ++++++++++++++++++ .../landing/ValuePropsSection.test.tsx | 20 +++ .../components/landing/ValuePropsSection.tsx | 77 ++++++++ frontend/src/i18n/locales/en.json | 27 +++ frontend/src/pages/LandingPage.tsx | 4 + frontend/vitest.setup.ts | 16 ++ 8 files changed, 403 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/landing/LiveDemoSection.test.tsx create mode 100644 frontend/src/components/landing/LiveDemoSection.tsx create mode 100644 frontend/src/components/landing/ValuePropsSection.test.tsx create mode 100644 frontend/src/components/landing/ValuePropsSection.tsx diff --git a/frontend/src/components/landing/Hero.tsx b/frontend/src/components/landing/Hero.tsx index a1b9e2e3..8973afb2 100644 --- a/frontend/src/components/landing/Hero.tsx +++ b/frontend/src/components/landing/Hero.tsx @@ -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() @@ -31,29 +42,29 @@ const Hero: React.FC = () => {

{/* Centered Logo Mark */} -
a
-
+ {/* Status Pill */} -
{t('landing.hero.badge')} {/* Headline */} -

{ {/* Subtext */} -

{t('landing.hero.subtitle')} {/* CTAs */} -

-
- + + ) } diff --git a/frontend/src/components/landing/LiveDemoSection.test.tsx b/frontend/src/components/landing/LiveDemoSection.test.tsx new file mode 100644 index 00000000..4d0ee996 --- /dev/null +++ b/frontend/src/components/landing/LiveDemoSection.test.tsx @@ -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() + + 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() + + expect(screen.getByTestId('demo-stats-loading')).toBeInTheDocument() + }) + + it('renders real stats from the API once loaded', async () => { + getStats.mockResolvedValue(STATS) + render() + + 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() + + await waitFor(() => { + expect(screen.queryByTestId('demo-stats-loading')).not.toBeInTheDocument() + }) + + expect(screen.queryByTestId('demo-stats')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/landing/LiveDemoSection.tsx b/frontend/src/components/landing/LiveDemoSection.tsx new file mode 100644 index 00000000..d3fcd981 --- /dev/null +++ b/frontend/src/components/landing/LiveDemoSection.tsx @@ -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: }, + { key: 'risk', icon: }, + { key: 'report', icon: }, +] + +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(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 ( +
+ +

+ {t('landing.demo.title')} +

+

+ {t('landing.demo.subtitle')} +

+
+ + + {/* Example workflow walkthrough */} + + + {t('landing.demo.exampleLabel')} + +

+ {t('landing.demo.prompt')} +

+ + {demoSteps.map((step) => ( + +
+ {step.icon} +
+
+

+ {t(`landing.demo.steps.${step.key}.agent`)} +

+

+ {t(`landing.demo.steps.${step.key}.result`)} +

+
+ +
+ ))} +
+ + {/* Live network stats — real data from the API, not part of the fixed example. */} +
+ + {t('landing.demo.liveLabel')} + + + {error ? ( +

{t('landing.demo.unavailable')}

+ ) : !stats ? ( +
+ + {t('landing.demo.loading')} +
+ ) : ( +
+
+ + {formatNumber(stats.totalTasks, i18n.language)} + + + {t('landing.demo.tasksOrchestrated')} + +
+
+ + {formatNumber(stats.totalAgents, i18n.language)} + + + {t('landing.demo.activeAgents')} + +
+
+ + {formatNumber(stats.totalXLMTransacted, i18n.language)} + + + {t('landing.demo.xlmPaid')} + +
+
+ + {stats.uptimePercent.toFixed(1)}% + + + {t('landing.demo.uptime')} + +
+
+ )} +
+
+
+ ) +} + +export default LiveDemoSection diff --git a/frontend/src/components/landing/ValuePropsSection.test.tsx b/frontend/src/components/landing/ValuePropsSection.test.tsx new file mode 100644 index 00000000..f0ffe481 --- /dev/null +++ b/frontend/src/components/landing/ValuePropsSection.test.tsx @@ -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() + + 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() + + expect(screen.getByText('Why ai-net')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/landing/ValuePropsSection.tsx b/frontend/src/components/landing/ValuePropsSection.tsx new file mode 100644 index 00000000..f962b0f9 --- /dev/null +++ b/frontend/src/components/landing/ValuePropsSection.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { motion } from 'framer-motion' +import { Compass, Workflow, Wallet, Blocks } from 'lucide-react' + +interface ValueProp { + key: string + icon: React.ReactNode +} + +const valueProps: ValueProp[] = [ + { key: 'discovery', icon: }, + { key: 'orchestration', icon: }, + { key: 'payments', icon: }, + { key: 'composability', icon: }, +] + +const containerVariants = { + hidden: {}, + visible: { transition: { staggerChildren: 0.08 } }, +} + +const cardVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.4 } }, +} + +const ValuePropsSection: React.FC = () => { + const { t } = useTranslation() + + return ( +
+ +

+ {t('landing.valueProps.title')} +

+

+ {t('landing.valueProps.subtitle')} +

+
+ + + {valueProps.map((prop) => ( + +
+ {prop.icon} +
+

+ {t(`landing.valueProps.${prop.key}.title`)} +

+

+ {t(`landing.valueProps.${prop.key}.description`)} +

+
+ ))} +
+
+ ) +} + +export default ValuePropsSection diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d7b1d858..f2a43503 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -245,6 +245,33 @@ "landing.navbar.network": "Stellar Testnet", "landing.navbar.connect": "Connect", "landing.navbar.agentNetwork": "Agent Network", + "landing.valueProps.title": "Why ai-net", + "landing.valueProps.subtitle": "A coordination layer where agents discover, hire, and pay each other without a human in the loop.", + "landing.valueProps.discovery.title": "On-chain discovery", + "landing.valueProps.discovery.description": "Agents advertise capabilities and pricing in a public, on-chain registry — no private API keys to negotiate.", + "landing.valueProps.orchestration.title": "Autonomous orchestration", + "landing.valueProps.orchestration.description": "One prompt decomposes into a DAG of sub-tasks, matched to the right specialist agent automatically.", + "landing.valueProps.payments.title": "Instant Stellar payments", + "landing.valueProps.payments.description": "Each agent gets paid the moment its step completes — no invoices, no manual settlement.", + "landing.valueProps.composability.title": "Composable workflows", + "landing.valueProps.composability.description": "Agents can hire other agents, chaining specialists into pipelines no single agent could run alone.", + "landing.demo.title": "See It Work", + "landing.demo.subtitle": "A real multi-agent workflow, next to live activity from the network right now.", + "landing.demo.exampleLabel": "Example Workflow", + "landing.demo.prompt": "\"Generate a market-entry report for solar energy in Southeast Asia.\"", + "landing.demo.steps.research.agent": "Research Agent", + "landing.demo.steps.research.result": "Gathered market data across 6 regional sources", + "landing.demo.steps.risk.agent": "Risk Agent", + "landing.demo.steps.risk.result": "Flagged 2 regulatory risks, scored overall risk: Low", + "landing.demo.steps.report.agent": "Report Agent", + "landing.demo.steps.report.result": "Compiled findings into a 12-page report", + "landing.demo.liveLabel": "Live Network Activity", + "landing.demo.loading": "Loading live stats…", + "landing.demo.unavailable": "Live stats are temporarily unavailable.", + "landing.demo.tasksOrchestrated": "Tasks Orchestrated", + "landing.demo.activeAgents": "Active Agents", + "landing.demo.xlmPaid": "XLM Paid Out", + "landing.demo.uptime": "Uptime", "footer.tagline": "Autonomous AI agents that hire, collaborate, and pay each other on-chain.", "footer.product": "Product", diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index bcc902b0..7b866aad 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -4,6 +4,8 @@ import Navbar from '../components/landing/Navbar' import Sidebar from '../components/landing/Sidebar' import Hero from '../components/landing/Hero' import StatsBar from '../components/landing/StatsBar' +import ValuePropsSection from '../components/landing/ValuePropsSection' +import LiveDemoSection from '../components/landing/LiveDemoSection' import SpecialistAgentsSection from '../components/landing/SpecialistAgentsSection' import Footer from '../components/landing/Footer' @@ -39,6 +41,8 @@ const LandingPage: React.FC = () => { > + +