From bec1f4fd5db27cfe80f2393c21393927fd469211 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:52:43 +0530 Subject: [PATCH] feat(agent-world): show live data on the Explore page (#3839) Co-authored-by: Steven Enamakel --- app/src/agentworld/pages/ExploreSection.tsx | 174 ---- .../ExploreSection/ExploreSection.test.tsx | 575 +++++++++++++ .../agentworld/pages/ExploreSection/index.tsx | 788 ++++++++++++++++++ app/src/lib/agentworld/invokeApiClient.ts | 4 + app/src/lib/i18n/ar.ts | 11 + app/src/lib/i18n/bn.ts | 11 + app/src/lib/i18n/de.ts | 11 + app/src/lib/i18n/en.ts | 11 + app/src/lib/i18n/es.ts | 11 + app/src/lib/i18n/fr.ts | 11 + app/src/lib/i18n/hi.ts | 11 + app/src/lib/i18n/id.ts | 11 + app/src/lib/i18n/it.ts | 11 + app/src/lib/i18n/ko.ts | 11 + app/src/lib/i18n/pl.ts | 11 + app/src/lib/i18n/pt.ts | 11 + app/src/lib/i18n/ru.ts | 11 + app/src/lib/i18n/zh-CN.ts | 11 + 18 files changed, 1521 insertions(+), 174 deletions(-) delete mode 100644 app/src/agentworld/pages/ExploreSection.tsx create mode 100644 app/src/agentworld/pages/ExploreSection/ExploreSection.test.tsx create mode 100644 app/src/agentworld/pages/ExploreSection/index.tsx diff --git a/app/src/agentworld/pages/ExploreSection.tsx b/app/src/agentworld/pages/ExploreSection.tsx deleted file mode 100644 index 97e0e5c78..000000000 --- a/app/src/agentworld/pages/ExploreSection.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/** - * ExploreSection — Agent World "Explore" overview. - * - * Calls `explorer.overview()` via the invoke API client (native Rust core → - * tiny.place SDK) and renders the network overview as stat cards inside the - * standard `PanelScaffold` chrome, matching the rest of the app. Handles - * loading / wallet-locked / payment / error states. - */ -import { useEffect, useState } from 'react'; - -import PanelScaffold from '../../components/layout/PanelScaffold'; -import { type ExplorerOverview, PaymentRequiredError } from '../../lib/agentworld/invokeApiClient'; -import { apiClient } from '../AgentWorldShell'; - -type State = - | { status: 'loading' } - | { status: 'payment_required'; challenge: unknown } - | { status: 'error'; message: string } - | { status: 'ok'; data: ExplorerOverview }; - -/** Defensive view of the explorer overview payload (fields are best-effort). */ -interface OverviewShape { - allTime?: { feesUsd?: string; registeredAgents?: number; volumeUsd?: string }; - last24h?: { feesUsd?: string; transactions?: number; uniqueAgents?: number; volumeUsd?: string }; - ledger?: { totalEntries?: number; latestTxId?: string; latestTimestamp?: string }; -} - -function useExplorerOverview(): State { - const [state, setState] = useState({ status: 'loading' }); - - useEffect(() => { - let cancelled = false; - - void apiClient.explorer - .overview() - .then(data => { - if (!cancelled) setState({ status: 'ok', data }); - }) - .catch((err: unknown) => { - if (cancelled) return; - if (err instanceof PaymentRequiredError) { - setState({ status: 'payment_required', challenge: err.challenge }); - } else { - setState({ status: 'error', message: String(err) }); - } - }); - - return () => { - cancelled = true; - }; - }, []); - - return state; -} - -function usd(value?: string): string { - if (value == null) return '—'; - const n = Number(value); - if (Number.isNaN(n)) return `$${value}`; - return `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; -} - -function num(value?: number): string { - return value == null ? '—' : value.toLocaleString(); -} - -function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) { - return ( -
-
- {label} -
-
- {value} -
- {sub &&
{sub}
} -
- ); -} - -/** Centered status message used for loading / wallet / error states. */ -function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) { - return ( -
-

{title}

- {body &&

{body}

} -
- ); -} - -export default function ExploreSection() { - const state = useExplorerOverview(); - - let body: React.ReactNode; - - if (state.status === 'loading') { - body = ( -
- Loading network overview… -
- ); - } else if (state.status === 'payment_required') { - body = ( - - ); - } else if (state.status === 'error') { - const isWalletLocked = - state.message.includes('wallet is not configured') || - state.message.includes('wallet secret material is missing'); - body = isWalletLocked ? ( - - ) : ( - - ); - } else { - const ov = state.data as unknown as OverviewShape; - body = ( - <> -
-

- All time -

-
- - - -
-
-
-

- Last 24 hours -

-
- - - - -
-
-
-

- Ledger -

-
- - -
-
- - ); - } - - return {body}; -} diff --git a/app/src/agentworld/pages/ExploreSection/ExploreSection.test.tsx b/app/src/agentworld/pages/ExploreSection/ExploreSection.test.tsx new file mode 100644 index 000000000..7fbde954d --- /dev/null +++ b/app/src/agentworld/pages/ExploreSection/ExploreSection.test.tsx @@ -0,0 +1,575 @@ +/** + * Tests for ExploreSection — Agent World Explore page. + * + * The page renders: + * 1. Network stats via apiClient.explorer.overview() — full StatusBlock states + * (loading / payment_required / wallet_locked / generic error / ok). + * 2. Four independent live sections: + * - Trending Communities → apiClient.groups.list() + * - Active Jobs → apiClient.graphql.jobs() + * - Featured Bounties → apiClient.bounties.list() + * - New Agents → apiClient.directory.listAgents() + * Each section independently handles loading (skeleton) / ok / empty / error + * (silent degrade — section hidden). Mocks prevent real RPC calls. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { PaymentRequiredError } from '../../../lib/agentworld/invokeApiClient'; +import { apiClient } from '../../AgentWorldShell'; +import ExploreSection from './index'; + +// ── Mock apiClient ──────────────────────────────────────────────────────────── + +vi.mock('../../AgentWorldShell', () => ({ + apiClient: { + explorer: { overview: vi.fn() }, + groups: { list: vi.fn() }, + graphql: { jobs: vi.fn() }, + bounties: { list: vi.fn() }, + directory: { listAgents: vi.fn() }, + }, +})); + +// ── Mock react-router-dom useNavigate ───────────────────────────────────────── + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +// ── Convenience typed mocks ─────────────────────────────────────────────────── + +const mockOverview = vi.mocked(apiClient.explorer.overview); +const mockGroupsList = vi.mocked(apiClient.groups.list); +const mockGraphqlJobs = vi.mocked(apiClient.graphql.jobs); +const mockBountiesList = vi.mocked(apiClient.bounties.list); +const mockListAgents = vi.mocked(apiClient.directory.listAgents); + +// ── Sample fixtures ─────────────────────────────────────────────────────────── + +const OVERVIEW_OK = { + allTime: { registeredAgents: 42, volumeUsd: '1000.00', feesUsd: '10.00' }, + last24h: { transactions: 5, uniqueAgents: 3, volumeUsd: '100.00', feesUsd: '1.00' }, + ledger: { totalEntries: 99, latestTxId: 'tx-abc', latestTimestamp: '2024-01-01T00:00:00Z' }, +}; + +const GROUPS_OK = [ + { + groupId: 'g-1', + name: 'Alpha Community', + description: 'First community', + createdBy: 'user1', + createdAt: '2024-01-01T00:00:00Z', + membershipPolicy: 'open', + memberCount: 100, + membershipEpoch: 1, + tags: ['defi', 'ai'], + }, + { + groupId: 'g-2', + name: 'Beta Community', + description: 'Second community', + createdBy: 'user2', + createdAt: '2024-01-02T00:00:00Z', + membershipPolicy: 'open', + memberCount: 50, + membershipEpoch: 1, + }, +]; + +const JOBS_OK = { + jobs: [ + { + jobId: 'job-1', + client: 'client-addr', + title: 'Build a DeFi Dashboard', + description: 'Need a developer', + skills: ['React', 'TypeScript'], + budget: { amount: '500', asset: 'USDC' }, + status: 'open', + proposalCount: 3, + createdAt: '2024-01-15T00:00:00Z', + updatedAt: '2024-01-15T00:00:00Z', + clientProfile: { + handle: 'client1', + cryptoId: 'addr1', + displayName: 'Client One', + verified: false, + }, + }, + ], + count: 1, +}; + +const BOUNTIES_OK = { + bounties: [ + { + bountyId: 'b-1', + creator: 'creator-addr', + title: 'Fix Critical Bug', + description: 'Critical bug in our system', + reward: { amount: '250', asset: 'SOL' }, + status: 'open', + submissionCount: 2, + commentCount: 0, + createdAt: '2024-01-10T00:00:00Z', + updatedAt: '2024-01-10T00:00:00Z', + deadline: '2024-03-01T00:00:00Z', + }, + ], +}; + +const AGENTS_OK = { + agents: [ + { agentId: 'agent-1', name: 'Nexus', username: 'nexus', description: 'An AI research agent' }, + { agentId: 'agent-2', username: '@aurora', description: 'A creative agent' }, + ], +}; + +// ── Helper: render inside MemoryRouter ──────────────────────────────────────── + +function renderExplore() { + return render( + + + + ); +} + +// ── beforeEach: resolve all calls with success data ─────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + mockNavigate.mockReset(); + mockOverview.mockResolvedValue( + OVERVIEW_OK as unknown as Awaited> + ); + mockGroupsList.mockResolvedValue(GROUPS_OK); + mockGraphqlJobs.mockResolvedValue(JOBS_OK); + mockBountiesList.mockResolvedValue( + BOUNTIES_OK as unknown as Awaited> + ); + mockListAgents.mockResolvedValue(AGENTS_OK); +}); + +// ── Stats + all sections render ─────────────────────────────────────────────── + +describe('fully populated state', () => { + test('renders network overview section heading', async () => { + renderExplore(); + // "Network Overview" appears both as the PanelScaffold description and the

; + // use findAllByText so the duplicate doesn't throw. + const matches = await screen.findAllByText('Network Overview'); + expect(matches.length).toBeGreaterThanOrEqual(1); + }); + + test('renders all-time stat card with registered agents', async () => { + renderExplore(); + expect(await screen.findByText('42')).toBeInTheDocument(); + }); + + test('renders trending communities section heading', async () => { + renderExplore(); + expect(await screen.findByText('Trending Communities')).toBeInTheDocument(); + }); + + test('renders community cards sorted by member count desc', async () => { + renderExplore(); + expect(await screen.findByText('Alpha Community')).toBeInTheDocument(); + expect(screen.getByText('Beta Community')).toBeInTheDocument(); + // Alpha (100 members) should appear before Beta (50 members) — test ordering. + const allNames = screen.getAllByText(/Community/); + expect(allNames[0].textContent).toBe('Alpha Community'); + }); + + test('renders community member count and tags', async () => { + renderExplore(); + expect(await screen.findByText('100 members')).toBeInTheDocument(); + expect(screen.getByText('defi')).toBeInTheDocument(); + expect(screen.getByText('ai')).toBeInTheDocument(); + }); + + test('renders active jobs section heading', async () => { + renderExplore(); + expect(await screen.findByText('Active Jobs')).toBeInTheDocument(); + }); + + test('renders job card with title, budget and skills', async () => { + renderExplore(); + expect(await screen.findByText('Build a DeFi Dashboard')).toBeInTheDocument(); + expect(screen.getByText('500 USDC')).toBeInTheDocument(); + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('TypeScript')).toBeInTheDocument(); + }); + + test('renders job client display name', async () => { + renderExplore(); + expect(await screen.findByText(/Client One/)).toBeInTheDocument(); + }); + + test('renders featured bounties section heading', async () => { + renderExplore(); + expect(await screen.findByText('Featured Bounties')).toBeInTheDocument(); + }); + + test('renders bounty card with title, reward and submission count', async () => { + renderExplore(); + expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument(); + expect(screen.getByText('250 SOL')).toBeInTheDocument(); + expect(screen.getByText(/2 submissions/)).toBeInTheDocument(); + }); + + test('renders new agents section heading', async () => { + renderExplore(); + expect(await screen.findByText('New Agents')).toBeInTheDocument(); + }); + + test('renders agent mini cards with handles', async () => { + renderExplore(); + expect(await screen.findByText('@nexus')).toBeInTheDocument(); + expect(screen.getByText('@aurora')).toBeInTheDocument(); + }); + + test('renders agent description', async () => { + renderExplore(); + expect(await screen.findByText('An AI research agent')).toBeInTheDocument(); + }); + + test('renders "View all" buttons for each live section', async () => { + renderExplore(); + // Wait for sections to load. + await screen.findByText('Trending Communities'); + const viewAllButtons = screen.getAllByText('View all'); + expect(viewAllButtons.length).toBeGreaterThanOrEqual(4); + }); +}); + +// ── Loading state ───────────────────────────────────────────────────────────── + +describe('loading state', () => { + test('renders skeleton placeholders for communities while loading', () => { + mockOverview.mockReturnValue(new Promise(() => {})); + mockGroupsList.mockReturnValue(new Promise(() => {})); + mockGraphqlJobs.mockReturnValue(new Promise(() => {})); + mockBountiesList.mockReturnValue(new Promise(() => {})); + mockListAgents.mockReturnValue(new Promise(() => {})); + + const { container } = renderExplore(); + const skeletons = container.querySelectorAll('.animate-pulse'); + // 4 sections × 4-8 skeletons each, at least one group exists. + expect(skeletons.length).toBeGreaterThan(0); + }); + + test('shows network loading text while overview loads', () => { + mockOverview.mockReturnValue(new Promise(() => {})); + mockGroupsList.mockResolvedValue([]); + mockGraphqlJobs.mockResolvedValue({ jobs: [], count: 0 }); + mockBountiesList.mockResolvedValue({ bounties: [] } as unknown as Awaited< + ReturnType + >); + mockListAgents.mockResolvedValue({ agents: [] }); + + renderExplore(); + expect(screen.getByText(/Loading network overview/i)).toBeInTheDocument(); + }); +}); + +// ── Empty sections ──────────────────────────────────────────────────────────── + +describe('empty sections', () => { + beforeEach(() => { + mockGroupsList.mockResolvedValue([]); + mockGraphqlJobs.mockResolvedValue({ jobs: [], count: 0 }); + mockBountiesList.mockResolvedValue({ bounties: [] } as unknown as Awaited< + ReturnType + >); + mockListAgents.mockResolvedValue({ agents: [] }); + }); + + test('shows no-communities message when groups list is empty', async () => { + renderExplore(); + expect(await screen.findByText('No communities yet')).toBeInTheDocument(); + }); + + test('shows no-jobs message when jobs list is empty', async () => { + renderExplore(); + expect(await screen.findByText('No active jobs')).toBeInTheDocument(); + }); + + test('shows no-bounties message when bounties list is empty', async () => { + renderExplore(); + expect(await screen.findByText('No open bounties')).toBeInTheDocument(); + }); + + test('shows no-agents message when directory returns no agents', async () => { + renderExplore(); + expect(await screen.findByText('No agents registered')).toBeInTheDocument(); + }); + + test('still renders stats section when entity sections are empty', async () => { + renderExplore(); + // Stats resolve fine. + expect(await screen.findByText('42')).toBeInTheDocument(); + // Empty messages present. + expect(await screen.findByText('No communities yet')).toBeInTheDocument(); + }); + + test('hides community section entirely when groups.list returns []', async () => { + renderExplore(); + // Wait for resolution. + await screen.findByText('No communities yet'); + // No community card names appear. + expect(screen.queryByText('Alpha Community')).not.toBeInTheDocument(); + }); +}); + +// ── Bounties open-only client-side filter ───────────────────────────────────── + +describe('bounties client-side status filter', () => { + test('filters out non-open bounties returned by the server', async () => { + mockBountiesList.mockResolvedValue({ + bounties: [ + { + bountyId: 'b-open', + creator: 'c', + title: 'Open Bounty', + description: 'desc', + reward: { amount: '100', asset: 'SOL' }, + status: 'open', + submissionCount: 0, + commentCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + { + bountyId: 'b-closed', + creator: 'c', + title: 'Closed Bounty', + description: 'desc', + reward: { amount: '50', asset: 'SOL' }, + status: 'closed', + submissionCount: 1, + commentCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ], + } as unknown as Awaited>); + + renderExplore(); + expect(await screen.findByText('Open Bounty')).toBeInTheDocument(); + expect(screen.queryByText('Closed Bounty')).not.toBeInTheDocument(); + }); + + test('shows empty state when all returned bounties are non-open', async () => { + mockBountiesList.mockResolvedValue({ + bounties: [ + { + bountyId: 'b-closed', + creator: 'c', + title: 'Closed Only', + description: 'desc', + reward: { amount: '50', asset: 'SOL' }, + status: 'closed', + submissionCount: 0, + commentCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ], + } as unknown as Awaited>); + + renderExplore(); + expect(await screen.findByText('No open bounties')).toBeInTheDocument(); + expect(screen.queryByText('Closed Only')).not.toBeInTheDocument(); + }); +}); + +// ── Section error graceful degrade ──────────────────────────────────────────── + +describe('section error: graceful degrade', () => { + test('hides communities section on groups.list error; other sections still render', async () => { + mockGroupsList.mockRejectedValue(new Error('communities API down')); + + renderExplore(); + // Other sections still resolve. + expect(await screen.findByText('Build a DeFi Dashboard')).toBeInTheDocument(); + expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument(); + expect(await screen.findByText('@nexus')).toBeInTheDocument(); + // Communities section entirely hidden. + expect(screen.queryByText('Trending Communities')).not.toBeInTheDocument(); + expect(screen.queryByText('Alpha Community')).not.toBeInTheDocument(); + expect(screen.queryByText('No communities yet')).not.toBeInTheDocument(); + }); + + test('hides jobs section on graphql.jobs error; other sections still render', async () => { + mockGraphqlJobs.mockRejectedValue(new Error('jobs API down')); + + renderExplore(); + expect(await screen.findByText('Alpha Community')).toBeInTheDocument(); + expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument(); + expect(await screen.findByText('@nexus')).toBeInTheDocument(); + expect(screen.queryByText('Active Jobs')).not.toBeInTheDocument(); + }); + + test('hides bounties section on bounties.list error; other sections still render', async () => { + mockBountiesList.mockRejectedValue(new Error('bounties API down')); + + renderExplore(); + expect(await screen.findByText('Alpha Community')).toBeInTheDocument(); + expect(await screen.findByText('Build a DeFi Dashboard')).toBeInTheDocument(); + expect(await screen.findByText('@nexus')).toBeInTheDocument(); + expect(screen.queryByText('Featured Bounties')).not.toBeInTheDocument(); + }); + + test('hides agents section on directory.listAgents error; other sections still render', async () => { + mockListAgents.mockRejectedValue(new Error('directory API down')); + + renderExplore(); + expect(await screen.findByText('Alpha Community')).toBeInTheDocument(); + expect(await screen.findByText('Build a DeFi Dashboard')).toBeInTheDocument(); + expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument(); + expect(screen.queryByText('New Agents')).not.toBeInTheDocument(); + }); + + test('page does not crash when all entity sections error', async () => { + mockGroupsList.mockRejectedValue(new Error('fail')); + mockGraphqlJobs.mockRejectedValue(new Error('fail')); + mockBountiesList.mockRejectedValue(new Error('fail')); + mockListAgents.mockRejectedValue(new Error('fail')); + + renderExplore(); + // Stats still render. + expect(await screen.findByText('42')).toBeInTheDocument(); + // No section headings for entity sections. + await waitFor(() => { + expect(screen.queryByText('Trending Communities')).not.toBeInTheDocument(); + expect(screen.queryByText('Active Jobs')).not.toBeInTheDocument(); + expect(screen.queryByText('Featured Bounties')).not.toBeInTheDocument(); + expect(screen.queryByText('New Agents')).not.toBeInTheDocument(); + }); + }); +}); + +// ── Stats section error states ──────────────────────────────────────────────── + +describe('stats section: wallet locked', () => { + test('shows wallet-locked StatusBlock when overview errors with wallet message', async () => { + mockOverview.mockRejectedValue(new Error('the wallet is not configured')); + + renderExplore(); + expect(await screen.findByText('Unlock your wallet to use Agent World')).toBeInTheDocument(); + expect(screen.getByText(/Import your recovery phrase in Settings/i)).toBeInTheDocument(); + }); + + test('shows wallet-locked StatusBlock for missing wallet secret material', async () => { + mockOverview.mockRejectedValue(new Error('wallet secret material is missing')); + + renderExplore(); + expect(await screen.findByText('Unlock your wallet to use Agent World')).toBeInTheDocument(); + }); + + test('shows generic error StatusBlock for unknown overview failure', async () => { + mockOverview.mockRejectedValue(new Error('network timeout')); + + renderExplore(); + expect(await screen.findByText('Failed to load Agent World')).toBeInTheDocument(); + expect(screen.getByText(/network timeout/i)).toBeInTheDocument(); + }); +}); + +describe('stats section: payment required', () => { + test('shows payment-required StatusBlock when overview throws PaymentRequiredError', async () => { + mockOverview.mockRejectedValue(new PaymentRequiredError({ terms: 'x402-v1' })); + + renderExplore(); + expect(await screen.findByText('Access requires payment')).toBeInTheDocument(); + expect(screen.getByText(/Your wallet will be used to fulfill/i)).toBeInTheDocument(); + }); +}); + +// ── Agent display name edge cases ───────────────────────────────────────────── + +describe('agent handle derivation', () => { + test('strips leading @ from username before re-adding it', async () => { + mockListAgents.mockResolvedValue({ agents: [{ agentId: 'a-1', username: '@preexisting' }] }); + + renderExplore(); + // Should show @preexisting not @@preexisting. + expect(await screen.findByText('@preexisting')).toBeInTheDocument(); + expect(screen.queryByText('@@preexisting')).not.toBeInTheDocument(); + }); + + test('falls back to name when username is absent', async () => { + mockListAgents.mockResolvedValue({ agents: [{ agentId: 'a-2', name: 'SolAgent' }] }); + + renderExplore(); + expect(await screen.findByText('@SolAgent')).toBeInTheDocument(); + }); + + test('falls back to first 8 chars of agentId when name and username absent', async () => { + mockListAgents.mockResolvedValue({ agents: [{ agentId: 'xyzabc1234567890' }] }); + + renderExplore(); + // displayName = agentId.slice(0,8) = 'xyzabc12' + expect(await screen.findByText('@xyzabc12')).toBeInTheDocument(); + }); +}); + +// ── Communities member count sort ───────────────────────────────────────────── + +describe('communities sort by memberCount', () => { + test('sorts communities by memberCount descending', async () => { + mockGroupsList.mockResolvedValue([ + { + groupId: 'low', + name: 'Small Group', + description: 'small', + createdBy: 'u1', + createdAt: '2024-01-01T00:00:00Z', + membershipPolicy: 'open', + memberCount: 5, + membershipEpoch: 1, + }, + { + groupId: 'high', + name: 'Big Group', + description: 'big', + createdBy: 'u2', + createdAt: '2024-01-01T00:00:00Z', + membershipPolicy: 'open', + memberCount: 500, + membershipEpoch: 1, + }, + ]); + + renderExplore(); + await screen.findByText('Big Group'); + // In the DOM, Big Group (500 members) should appear before Small Group (5 members). + const bigIdx = document.body.innerHTML.indexOf('Big Group'); + const smallIdx = document.body.innerHTML.indexOf('Small Group'); + expect(bigIdx).toBeLessThan(smallIdx); + }); +}); + +// ── Cancellation ────────────────────────────────────────────────────────────── + +describe('cancellation on unmount', () => { + test('does not update state after unmount (no act warning)', async () => { + let resolveGroups!: (v: typeof GROUPS_OK) => void; + mockGroupsList.mockReturnValue( + new Promise(r => { + resolveGroups = r; + }) + ); + + const { unmount } = renderExplore(); + unmount(); + resolveGroups(GROUPS_OK); + await waitFor(() => expect(mockGroupsList).toHaveBeenCalled()); + // No error — the cancelled flag swallowed the state update. + }); +}); diff --git a/app/src/agentworld/pages/ExploreSection/index.tsx b/app/src/agentworld/pages/ExploreSection/index.tsx new file mode 100644 index 000000000..a8d559691 --- /dev/null +++ b/app/src/agentworld/pages/ExploreSection/index.tsx @@ -0,0 +1,788 @@ +/** + * ExploreSection — Agent World "Explore" overview. + * + * Renders network stat cards at the top (via explorer.overview()) followed by + * four live-data sections that each fetch independently: + * - Trending Communities → apiClient.groups.list({ limit: 12 }) + * - Active Jobs → apiClient.graphql.jobs({ status: 'OPEN', limit: 6 }) + * - Featured Bounties → apiClient.bounties.list({ status: 'open', limit: 6 }) + * - New Agents → apiClient.directory.listAgents({ limit: 8 }) + * + * Each live section handles loading / empty / error independently; a failure in + * one section never crashes the page. The stats section uses a StatusBlock for + * wallet-locked / payment-required / hard error states. + */ +import debugFactory from 'debug'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import PanelScaffold from '../../../components/layout/PanelScaffold'; +import { + type AgentCard, + type Bounty, + type ExplorerOverview, + type GqlJobPosting, + type GroupMetadata, + PaymentRequiredError, +} from '../../../lib/agentworld/invokeApiClient'; +import { useT } from '../../../lib/i18n/I18nContext'; +import { apiClient } from '../../AgentWorldShell'; + +const debug = debugFactory('agentworld:explore'); + +// ── Shared card style ───────────────────────────────────────────────────────── + +const CARD_CLASS = + 'rounded-lg border border-stone-200 bg-white dark:border-neutral-800 dark:bg-neutral-900'; + +// ── Stats section types & hook ──────────────────────────────────────────────── + +type StatsState = + | { status: 'loading' } + | { status: 'payment_required'; challenge: unknown } + | { status: 'error'; message: string } + | { status: 'ok'; data: ExplorerOverview }; + +// OverviewShape removed — ExplorerOverview now carries typed allTime/last24h/ledger fields. + +function useExplorerOverview(): StatsState { + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + debug('fetching explorer overview'); + + void apiClient.explorer + .overview() + .then(data => { + if (cancelled) return; + debug('loaded explorer overview'); + setState({ status: 'ok', data }); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof PaymentRequiredError) { + debug('explorer overview: 402 payment_required'); + setState({ status: 'payment_required', challenge: err.challenge }); + } else { + debug('explorer overview: error: %s', String(err)); + setState({ status: 'error', message: String(err) }); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + return state; +} + +// ── Live section state type ─────────────────────────────────────────────────── + +type SectionState = + | { status: 'loading' } + | { status: 'ok'; data: T[] } + | { status: 'empty' } + | { status: 'error' }; + +// ── Communities hook ────────────────────────────────────────────────────────── + +function useExploreCommunities(): SectionState { + const [state, setState] = useState>({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + debug('fetching explore communities'); + + void apiClient.groups + .list({ limit: 12 }) + .then(raw => { + if (cancelled) return; + // Sort client-side by memberCount desc (no server-side sort param). + const sorted = [...raw].sort((a, b) => (b.memberCount ?? 0) - (a.memberCount ?? 0)); + if (sorted.length === 0) { + debug('communities section: empty, hiding'); + setState({ status: 'empty' }); + } else { + debug('loaded %d communities', sorted.length); + setState({ status: 'ok', data: sorted }); + } + }) + .catch(err => { + if (cancelled) return; + debug('communities fetch failed: %s', String(err)); + setState({ status: 'error' }); + }); + + return () => { + cancelled = true; + }; + }, []); + + return state; +} + +// ── Jobs hook ───────────────────────────────────────────────────────────────── + +function useExploreJobs(): SectionState { + const [state, setState] = useState>({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + debug('fetching explore jobs'); + + void apiClient.graphql + .jobs({ status: 'OPEN', limit: 6 }) + .then(result => { + if (cancelled) return; + const jobs = result.jobs ?? []; + if (jobs.length === 0) { + debug('jobs section: empty, hiding'); + setState({ status: 'empty' }); + } else { + debug('loaded %d jobs', jobs.length); + setState({ status: 'ok', data: jobs }); + } + }) + .catch(err => { + if (cancelled) return; + debug('jobs fetch failed: %s', String(err)); + setState({ status: 'error' }); + }); + + return () => { + cancelled = true; + }; + }, []); + + return state; +} + +// ── Bounties hook ───────────────────────────────────────────────────────────── + +function useExploreBounties(): SectionState { + const [state, setState] = useState>({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + debug('fetching explore bounties'); + + void apiClient.bounties + .list({ status: 'open', limit: 6 }) + .then(result => { + if (cancelled) return; + // Client-side filter to open status in case the server ignores the param. + const open = (result.bounties ?? []).filter(b => b.status === 'open'); + if (open.length === 0) { + debug('bounties section: empty, hiding'); + setState({ status: 'empty' }); + } else { + debug('loaded %d bounties', open.length); + setState({ status: 'ok', data: open }); + } + }) + .catch(err => { + if (cancelled) return; + debug('bounties fetch failed: %s', String(err)); + setState({ status: 'error' }); + }); + + return () => { + cancelled = true; + }; + }, []); + + return state; +} + +// ── Agents hook ─────────────────────────────────────────────────────────────── + +function useExploreAgents(): SectionState { + const [state, setState] = useState>({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + debug('fetching explore agents'); + + void apiClient.directory + .listAgents({ limit: 8 }) + .then(result => { + if (cancelled) return; + const agents = result.agents ?? []; + if (agents.length === 0) { + debug('agents section: empty, hiding'); + setState({ status: 'empty' }); + } else { + debug('loaded %d agents', agents.length); + setState({ status: 'ok', data: agents }); + } + }) + .catch(err => { + if (cancelled) return; + debug('agents fetch failed: %s', String(err)); + setState({ status: 'error' }); + }); + + return () => { + cancelled = true; + }; + }, []); + + return state; +} + +// ── Stat helper functions ───────────────────────────────────────────────────── + +function usd(value?: string): string { + if (value == null) return '—'; + const n = Number(value); + if (Number.isNaN(n)) return `$${value}`; + return `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function num(value?: number): string { + return value == null ? '—' : value.toLocaleString(); +} + +// ── Shared primitive components ─────────────────────────────────────────────── + +function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+ {sub &&
{sub}
} +
+ ); +} + +/** Centered status message for loading / wallet / hard error states of the stats block. */ +function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) { + return ( +
+

{title}

+ {body &&

{body}

} +
+ ); +} + +/** Section heading row with optional "View all" link. */ +function SectionHeader({ + title, + viewAllLabel, + onViewAll, +}: { + title: string; + viewAllLabel: string; + onViewAll: () => void; +}) { + return ( +
+

{title}

+ +
+ ); +} + +/** Inline empty message rendered inside a section when it has no data. */ +function SectionEmpty({ message }: { message: string }) { + return

{message}

; +} + +// ── Communities section ─────────────────────────────────────────────────────── + +function CommunitySkeletonGrid() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +function CommunityCard({ group }: { group: GroupMetadata }) { + const tags = group.tags ?? []; + return ( +
+
{group.name}
+ {group.description && ( +

+ {group.description} +

+ )} +
+ + {group.memberCount} members + + {tags.slice(0, 3).map(tag => ( + + {tag} + + ))} +
+
+ ); +} + +function ExploreCommunitiesGrid({ + state, + title, + viewAllLabel, + emptyMessage, + onViewAll, +}: { + state: SectionState; + title: string; + viewAllLabel: string; + emptyMessage: string; + onViewAll: () => void; +}) { + if (state.status === 'error') return null; + + return ( +
+ + {state.status === 'loading' && } + {state.status === 'empty' && } + {state.status === 'ok' && ( +
+ {state.data.map(group => ( + + ))} +
+ )} +
+ ); +} + +// ── Jobs section ────────────────────────────────────────────────────────────── + +function JobSkeletonList() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function relativeTime(isoDate: string): string { + const delta = Date.now() - new Date(isoDate).getTime(); + const mins = Math.floor(delta / 60_000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function JobRow({ job }: { job: GqlJobPosting }) { + const skills = job.skills ?? []; + return ( +
+
+
+
{job.title}
+
+ {job.clientProfile.displayName} · {relativeTime(job.createdAt)} +
+ {skills.length > 0 && ( +
+ {skills.slice(0, 4).map(skill => ( + + {skill} + + ))} +
+ )} +
+
+
+ {job.budget.amount} {job.budget.asset} +
+
+
+
+ ); +} + +function ExploreJobsList({ + state, + title, + viewAllLabel, + emptyMessage, + onViewAll, +}: { + state: SectionState; + title: string; + viewAllLabel: string; + emptyMessage: string; + onViewAll: () => void; +}) { + if (state.status === 'error') return null; + + return ( +
+ + {state.status === 'loading' && } + {state.status === 'empty' && } + {state.status === 'ok' && ( +
+ {state.data.map(job => ( + + ))} +
+ )} +
+ ); +} + +// ── Bounties section ────────────────────────────────────────────────────────── + +function BountySkeletonList() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function BountyRow({ bounty }: { bounty: Bounty }) { + return ( +
+
+
+
{bounty.title}
+
+ {bounty.submissionCount} submission{bounty.submissionCount !== 1 ? 's' : ''} + {bounty.deadline ? ` · deadline ${new Date(bounty.deadline).toLocaleDateString()}` : ''} +
+
+
+
+ {bounty.reward.amount} {bounty.reward.asset} +
+
+
+
+ ); +} + +function ExploreBountiesList({ + state, + title, + viewAllLabel, + emptyMessage, + onViewAll, +}: { + state: SectionState; + title: string; + viewAllLabel: string; + emptyMessage: string; + onViewAll: () => void; +}) { + if (state.status === 'error') return null; + + return ( +
+ + {state.status === 'loading' && } + {state.status === 'empty' && } + {state.status === 'ok' && ( +
+ {state.data.map(bounty => ( + + ))} +
+ )} +
+ ); +} + +// ── Agents section ──────────────────────────────────────────────────────────── + +const AVATAR_COLORS = [ + 'bg-blue-500', + 'bg-purple-500', + 'bg-pink-500', + 'bg-emerald-500', + 'bg-amber-500', + 'bg-cyan-500', + 'bg-rose-500', + 'bg-violet-500', +]; + +function agentAvatarColor(agentId: string): string { + let total = 0; + for (let i = 0; i < agentId.length; i++) { + total += agentId.charCodeAt(i); + } + return AVATAR_COLORS[total % AVATAR_COLORS.length] ?? 'bg-blue-500'; +} + +function agentDisplayName(agent: AgentCard): string { + return agent.username ?? agent.name ?? agent.agentId.slice(0, 8); +} + +function AgentSkeletonGrid() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +function AgentMiniCard({ agent }: { agent: AgentCard }) { + const displayName = agentDisplayName(agent); + const handle = '@' + displayName.replace(/^@+/, ''); + const initials = displayName.replace(/^@+/, '').slice(0, 2).toUpperCase(); + const colorClass = agentAvatarColor(agent.agentId); + + return ( +
+
+
+ {initials} +
+
{handle}
+ {agent.description && ( +

+ {agent.description} +

+ )} +
+
+ ); +} + +function ExploreAgentsGrid({ + state, + title, + viewAllLabel, + emptyMessage, + onViewAll, +}: { + state: SectionState; + title: string; + viewAllLabel: string; + emptyMessage: string; + onViewAll: () => void; +}) { + if (state.status === 'error') return null; + + return ( +
+ + {state.status === 'loading' && } + {state.status === 'empty' && } + {state.status === 'ok' && ( +
+ {state.data.map(agent => ( + + ))} +
+ )} +
+ ); +} + +// ── Network stats section (existing, preserved) ─────────────────────────────── + +function NetworkStatsSection({ state }: { state: StatsState }) { + if (state.status === 'loading') { + return ( +
+ Loading network overview… +
+ ); + } + if (state.status === 'payment_required') { + return ( + + ); + } + if (state.status === 'error') { + const isWalletLocked = + state.message.includes('wallet is not configured') || + state.message.includes('wallet secret material is missing'); + return isWalletLocked ? ( + + ) : ( + + ); + } + + const ov = state.data; + return ( +
+
+

+ All time +

+
+ + + +
+
+
+

+ Last 24 hours +

+
+ + + + +
+
+
+

+ Ledger +

+
+ + +
+
+
+ ); +} + +// ── Root component ───────────────────────────────────────────────────────────── + +export default function ExploreSection() { + const { t } = useT(); + const navigate = useNavigate(); + + const statsState = useExplorerOverview(); + const communitiesState = useExploreCommunities(); + const jobsState = useExploreJobs(); + const bountiesState = useExploreBounties(); + const agentsState = useExploreAgents(); + + return ( + +
+ {/* ── Network stats (top, always first) ── */} +
+

+ {t('explore.networkOverview')} +

+ +
+ + {/* ── Live data sections ── */} + { + // Navigate to Messaging (Groups tab) — no standalone communities route yet. + navigate('/agent-world/messaging'); + }} + /> + + { + navigate('/agent-world/jobs'); + }} + /> + + { + navigate('/agent-world/bounties'); + }} + /> + + { + navigate('/agent-world/directory'); + }} + /> +
+
+ ); +} diff --git a/app/src/lib/agentworld/invokeApiClient.ts b/app/src/lib/agentworld/invokeApiClient.ts index 935cf7341..4bae30f8b 100644 --- a/app/src/lib/agentworld/invokeApiClient.ts +++ b/app/src/lib/agentworld/invokeApiClient.ts @@ -88,6 +88,7 @@ export interface AgentCard { agentId: string; name?: string; description?: string; + username?: string; [key: string]: unknown; } @@ -97,6 +98,9 @@ export interface ListAgentsResponse { } export interface ExplorerOverview { + allTime?: { feesUsd?: string; registeredAgents?: number; volumeUsd?: string }; + last24h?: { feesUsd?: string; transactions?: number; uniqueAgents?: number; volumeUsd?: string }; + ledger?: { totalEntries?: number; latestTxId?: string; latestTimestamp?: string }; [key: string]: unknown; } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index b47eba485..f4e090dc5 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'الملفات الشخصية', 'agentWorld.marketplace': 'السوق', 'agentWorld.messaging': 'الرسائل', + // Agent World — Explore section live data + 'explore.networkOverview': 'نظرة عامة على الشبكة', + 'explore.trendingCommunities': 'المجتمعات الرائجة', + 'explore.activeJobs': 'الوظائف النشطة', + 'explore.featuredBounties': 'المكافآت المميزة', + 'explore.newAgents': 'وكلاء جدد', + 'explore.viewAll': 'عرض الكل', + 'explore.noCommunities': 'لا توجد مجتمعات بعد', + 'explore.noJobs': 'لا توجد وظائف نشطة', + 'explore.noBounties': 'لا توجد مكافآت مفتوحة', + 'explore.noAgents': 'لم يتم تسجيل أي وكلاء', 'agentWorld.jobs.deadlineFuture': 'يجب أن يكون الموعد النهائي للمقترح في المستقبل', 'nav.avatarMenu.account': 'الحساب', 'nav.avatarMenu.billing': 'الفواتير', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 5fbb3944c..64f363954 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'প্রোফাইল', 'agentWorld.marketplace': 'মার্কেটপ্লেস', 'agentWorld.messaging': 'বার্তা', + // Agent World — Explore section live data + 'explore.networkOverview': 'নেটওয়ার্ক ওভারভিউ', + 'explore.trendingCommunities': 'ট্রেন্ডিং কমিউনিটি', + 'explore.activeJobs': 'সক্রিয় চাকরি', + 'explore.featuredBounties': 'ফিচার্ড বাউন্টি', + 'explore.newAgents': 'নতুন এজেন্ট', + 'explore.viewAll': 'সব দেখুন', + 'explore.noCommunities': 'এখনো কোনো কমিউনিটি নেই', + 'explore.noJobs': 'কোনো সক্রিয় চাকরি নেই', + 'explore.noBounties': 'কোনো খোলা বাউন্টি নেই', + 'explore.noAgents': 'কোনো এজেন্ট নিবন্ধিত নেই', 'agentWorld.jobs.deadlineFuture': 'প্রস্তাবের সময়সীমা ভবিষ্যতে হতে হবে', 'nav.avatarMenu.account': 'অ্যাকাউন্ট', 'nav.avatarMenu.billing': 'বিলিং', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 48efc27a1..de46c7cec 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profile', 'agentWorld.marketplace': 'Marktplatz', 'agentWorld.messaging': 'Nachrichten', + // Agent World — Explore section live data + 'explore.networkOverview': 'Netzwerkübersicht', + 'explore.trendingCommunities': 'Trending-Communities', + 'explore.activeJobs': 'Aktive Jobs', + 'explore.featuredBounties': 'Empfohlene Bounties', + 'explore.newAgents': 'Neue Agenten', + 'explore.viewAll': 'Alle anzeigen', + 'explore.noCommunities': 'Noch keine Communities', + 'explore.noJobs': 'Keine aktiven Jobs', + 'explore.noBounties': 'Keine offenen Bounties', + 'explore.noAgents': 'Keine Agenten registriert', 'agentWorld.jobs.deadlineFuture': 'Die Angebotsfrist muss in der Zukunft liegen', 'nav.avatarMenu.account': 'Konto', 'nav.avatarMenu.billing': 'Abrechnung', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index c3ba97cde..fc99b6a69 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -37,6 +37,17 @@ const en: TranslationMap = { 'agentWorld.profiles': 'Profiles', 'agentWorld.marketplace': 'Marketplace', 'agentWorld.messaging': 'Messages', + // Agent World — Explore section live data + 'explore.networkOverview': 'Network Overview', + 'explore.trendingCommunities': 'Trending Communities', + 'explore.activeJobs': 'Active Jobs', + 'explore.featuredBounties': 'Featured Bounties', + 'explore.newAgents': 'New Agents', + 'explore.viewAll': 'View all', + 'explore.noCommunities': 'No communities yet', + 'explore.noJobs': 'No active jobs', + 'explore.noBounties': 'No open bounties', + 'explore.noAgents': 'No agents registered', 'agentWorld.jobs.deadlineFuture': 'Proposal deadline must be in the future', // Agent World — Settings section UI 'nav.avatarMenu.account': 'Account', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 6b5f30cca..b3b9bfd56 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Perfiles', 'agentWorld.marketplace': 'Mercado', 'agentWorld.messaging': 'Mensajes', + // Agent World — Explore section live data + 'explore.networkOverview': 'Resumen de red', + 'explore.trendingCommunities': 'Comunidades en tendencia', + 'explore.activeJobs': 'Trabajos activos', + 'explore.featuredBounties': 'Recompensas destacadas', + 'explore.newAgents': 'Nuevos agentes', + 'explore.viewAll': 'Ver todo', + 'explore.noCommunities': 'Aún no hay comunidades', + 'explore.noJobs': 'No hay trabajos activos', + 'explore.noBounties': 'No hay recompensas abiertas', + 'explore.noAgents': 'No hay agentes registrados', 'agentWorld.jobs.deadlineFuture': 'La fecha límite de la propuesta debe ser en el futuro', 'nav.avatarMenu.account': 'Cuenta', 'nav.avatarMenu.billing': 'Facturación', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 1d359d320..566a12353 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profils', 'agentWorld.marketplace': 'Marché', 'agentWorld.messaging': 'Messages', + // Agent World — Explore section live data + 'explore.networkOverview': 'Aperçu du réseau', + 'explore.trendingCommunities': 'Communautés tendance', + 'explore.activeJobs': 'Emplois actifs', + 'explore.featuredBounties': 'Primes en vedette', + 'explore.newAgents': 'Nouveaux agents', + 'explore.viewAll': 'Voir tout', + 'explore.noCommunities': 'Pas encore de communautés', + 'explore.noJobs': 'Aucun emploi actif', + 'explore.noBounties': 'Aucune prime ouverte', + 'explore.noAgents': 'Aucun agent enregistré', 'agentWorld.jobs.deadlineFuture': 'La date limite de la proposition doit être dans le futur', 'nav.avatarMenu.account': 'Compte', 'nav.avatarMenu.billing': 'Facturation', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index d8dca6440..d681788ae 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'प्रोफ़ाइल', 'agentWorld.marketplace': 'मार्केटप्लेस', 'agentWorld.messaging': 'संदेश', + // Agent World — Explore section live data + 'explore.networkOverview': 'नेटवर्क अवलोकन', + 'explore.trendingCommunities': 'ट्रेंडिंग समुदाय', + 'explore.activeJobs': 'सक्रिय नौकरियां', + 'explore.featuredBounties': 'विशेष इनाम', + 'explore.newAgents': 'नए एजेंट', + 'explore.viewAll': 'सब देखें', + 'explore.noCommunities': 'अभी तक कोई समुदाय नहीं', + 'explore.noJobs': 'कोई सक्रिय नौकरी नहीं', + 'explore.noBounties': 'कोई खुला इनाम नहीं', + 'explore.noAgents': 'कोई एजेंट पंजीकृत नहीं', 'agentWorld.jobs.deadlineFuture': 'प्रस्ताव की समय सीमा भविष्य में होनी चाहिए', 'nav.avatarMenu.account': 'खाता', 'nav.avatarMenu.billing': 'बिलिंग', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index de6e6081e..17bd90928 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profil', 'agentWorld.marketplace': 'Pasar', 'agentWorld.messaging': 'Pesan', + // Agent World — Explore section live data + 'explore.networkOverview': 'Ikhtisar Jaringan', + 'explore.trendingCommunities': 'Komunitas Trending', + 'explore.activeJobs': 'Pekerjaan Aktif', + 'explore.featuredBounties': 'Bounty Unggulan', + 'explore.newAgents': 'Agen Baru', + 'explore.viewAll': 'Lihat semua', + 'explore.noCommunities': 'Belum ada komunitas', + 'explore.noJobs': 'Tidak ada pekerjaan aktif', + 'explore.noBounties': 'Tidak ada bounty terbuka', + 'explore.noAgents': 'Tidak ada agen terdaftar', 'agentWorld.jobs.deadlineFuture': 'Batas waktu proposal harus di masa depan', 'nav.avatarMenu.account': 'Akun', 'nav.avatarMenu.billing': 'Tagihan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 826aece0e..c8fe32e24 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profili', 'agentWorld.marketplace': 'Mercato', 'agentWorld.messaging': 'Messaggi', + // Agent World — Explore section live data + 'explore.networkOverview': 'Panoramica della rete', + 'explore.trendingCommunities': 'Comunità di tendenza', + 'explore.activeJobs': 'Lavori attivi', + 'explore.featuredBounties': 'Taglie in evidenza', + 'explore.newAgents': 'Nuovi agenti', + 'explore.viewAll': 'Vedi tutto', + 'explore.noCommunities': 'Nessuna comunità ancora', + 'explore.noJobs': 'Nessun lavoro attivo', + 'explore.noBounties': 'Nessuna taglia aperta', + 'explore.noAgents': 'Nessun agente registrato', 'agentWorld.jobs.deadlineFuture': 'La scadenza della proposta deve essere nel futuro', 'nav.avatarMenu.account': 'Account', 'nav.avatarMenu.billing': 'Fatturazione', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index a14baef54..c2e9bdca4 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': '프로필', 'agentWorld.marketplace': '마켓플레이스', 'agentWorld.messaging': '메시지', + // Agent World — Explore section live data + 'explore.networkOverview': '네트워크 개요', + 'explore.trendingCommunities': '트렌딩 커뮤니티', + 'explore.activeJobs': '활성 일자리', + 'explore.featuredBounties': '추천 현상금', + 'explore.newAgents': '새 에이전트', + 'explore.viewAll': '전체 보기', + 'explore.noCommunities': '아직 커뮤니티가 없습니다', + 'explore.noJobs': '활성 일자리 없음', + 'explore.noBounties': '열린 현상금 없음', + 'explore.noAgents': '등록된 에이전트 없음', 'agentWorld.jobs.deadlineFuture': '제안 마감일은 미래여야 합니다', 'nav.avatarMenu.account': '계정', 'nav.avatarMenu.billing': '결제', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index e0a81c719..b6f8cb21c 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profile', 'agentWorld.marketplace': 'Rynek', 'agentWorld.messaging': 'Wiadomości', + // Agent World — Explore section live data + 'explore.networkOverview': 'Przegląd sieci', + 'explore.trendingCommunities': 'Popularne społeczności', + 'explore.activeJobs': 'Aktywne oferty pracy', + 'explore.featuredBounties': 'Wyróżnione nagrody', + 'explore.newAgents': 'Nowi agenci', + 'explore.viewAll': 'Zobacz wszystkie', + 'explore.noCommunities': 'Brak społeczności', + 'explore.noJobs': 'Brak aktywnych ofert', + 'explore.noBounties': 'Brak otwartych nagród', + 'explore.noAgents': 'Brak zarejestrowanych agentów', 'agentWorld.jobs.deadlineFuture': 'Termin złożenia oferty musi być w przyszłości', 'nav.avatarMenu.account': 'Konto', 'nav.avatarMenu.billing': 'Rozliczenia', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index abde792f9..16d69c60f 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Perfis', 'agentWorld.marketplace': 'Mercado', 'agentWorld.messaging': 'Mensagens', + // Agent World — Explore section live data + 'explore.networkOverview': 'Visão geral da rede', + 'explore.trendingCommunities': 'Comunidades em alta', + 'explore.activeJobs': 'Empregos ativos', + 'explore.featuredBounties': 'Recompensas em destaque', + 'explore.newAgents': 'Novos agentes', + 'explore.viewAll': 'Ver tudo', + 'explore.noCommunities': 'Ainda sem comunidades', + 'explore.noJobs': 'Nenhum emprego ativo', + 'explore.noBounties': 'Nenhuma recompensa aberta', + 'explore.noAgents': 'Nenhum agente registrado', 'agentWorld.jobs.deadlineFuture': 'O prazo da proposta deve ser no futuro', 'nav.avatarMenu.account': 'Conta', 'nav.avatarMenu.billing': 'Faturamento', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index aa882e246..eb215f6f5 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Профили', 'agentWorld.marketplace': 'Маркетплейс', 'agentWorld.messaging': 'Сообщения', + // Agent World — Explore section live data + 'explore.networkOverview': 'Обзор сети', + 'explore.trendingCommunities': 'Трендовые сообщества', + 'explore.activeJobs': 'Активные вакансии', + 'explore.featuredBounties': 'Рекомендуемые награды', + 'explore.newAgents': 'Новые агенты', + 'explore.viewAll': 'Смотреть все', + 'explore.noCommunities': 'Пока нет сообществ', + 'explore.noJobs': 'Нет активных вакансий', + 'explore.noBounties': 'Нет открытых наград', + 'explore.noAgents': 'Нет зарегистрированных агентов', 'agentWorld.jobs.deadlineFuture': 'Срок подачи предложения должен быть в будущем', 'nav.avatarMenu.account': 'Аккаунт', 'nav.avatarMenu.billing': 'Оплата', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 6241d23f8..30bbf8a51 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -52,6 +52,17 @@ const messages: TranslationMap = { 'agentWorld.profiles': '档案', 'agentWorld.marketplace': '市场', 'agentWorld.messaging': '消息', + // Agent World — Explore section live data + 'explore.networkOverview': '网络概览', + 'explore.trendingCommunities': '热门社区', + 'explore.activeJobs': '活跃职位', + 'explore.featuredBounties': '精选赏金', + 'explore.newAgents': '新代理', + 'explore.viewAll': '查看全部', + 'explore.noCommunities': '暂无社区', + 'explore.noJobs': '暂无活跃职位', + 'explore.noBounties': '暂无开放赏金', + 'explore.noAgents': '暂无注册代理', 'agentWorld.jobs.deadlineFuture': '提案截止日期必须是将来的日期', 'nav.avatarMenu.account': '账户', 'nav.avatarMenu.billing': '账单',