mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent-world): show live data on the Explore page (#3839)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
4f6b856b6d
commit
bec1f4fd5d
@@ -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<State>({ 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 (
|
||||
<div className="rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1.5 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="mt-0.5 text-xs text-stone-400 dark:text-neutral-500">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Centered status message used for loading / wallet / error states. */
|
||||
function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-center">
|
||||
<p className={`text-base font-medium ${tone}`}>{title}</p>
|
||||
{body && <p className="max-w-md text-sm text-stone-500 dark:text-neutral-400">{body}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExploreSection() {
|
||||
const state = useExplorerOverview();
|
||||
|
||||
let body: React.ReactNode;
|
||||
|
||||
if (state.status === 'loading') {
|
||||
body = (
|
||||
<div className="flex h-64 items-center justify-center text-stone-400 dark:text-neutral-500">
|
||||
<span className="animate-pulse text-sm">Loading network overview…</span>
|
||||
</div>
|
||||
);
|
||||
} else if (state.status === 'payment_required') {
|
||||
body = (
|
||||
<StatusBlock
|
||||
tone="text-amber-600 dark:text-amber-400"
|
||||
title="Access requires payment"
|
||||
body="Your wallet will be used to fulfill the x402 payment challenge."
|
||||
/>
|
||||
);
|
||||
} else if (state.status === 'error') {
|
||||
const isWalletLocked =
|
||||
state.message.includes('wallet is not configured') ||
|
||||
state.message.includes('wallet secret material is missing');
|
||||
body = isWalletLocked ? (
|
||||
<StatusBlock
|
||||
tone="text-stone-700 dark:text-neutral-200"
|
||||
title="Unlock your wallet to use Agent World"
|
||||
body="Agent World uses your wallet identity. Import your recovery phrase in Settings to continue."
|
||||
/>
|
||||
) : (
|
||||
<StatusBlock
|
||||
tone="text-red-600 dark:text-red-400"
|
||||
title="Failed to load Agent World"
|
||||
body={state.message}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const ov = state.data as unknown as OverviewShape;
|
||||
body = (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
All time
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Registered agents" value={num(ov.allTime?.registeredAgents)} />
|
||||
<StatCard label="Volume" value={usd(ov.allTime?.volumeUsd)} />
|
||||
<StatCard label="Fees" value={usd(ov.allTime?.feesUsd)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
Last 24 hours
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Transactions" value={num(ov.last24h?.transactions)} />
|
||||
<StatCard label="Active agents" value={num(ov.last24h?.uniqueAgents)} />
|
||||
<StatCard label="Volume" value={usd(ov.last24h?.volumeUsd)} />
|
||||
<StatCard label="Fees" value={usd(ov.last24h?.feesUsd)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
Ledger
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Total entries" value={num(ov.ledger?.totalEntries)} />
|
||||
<StatCard
|
||||
label="Latest tx"
|
||||
value={ov.ledger?.latestTxId ?? '—'}
|
||||
sub={
|
||||
ov.ledger?.latestTimestamp
|
||||
? new Date(ov.ledger.latestTimestamp).toLocaleString()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <PanelScaffold description="Network overview">{body}</PanelScaffold>;
|
||||
}
|
||||
@@ -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<typeof import('react-router-dom')>('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(
|
||||
<MemoryRouter>
|
||||
<ExploreSection />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
// ── beforeEach: resolve all calls with success data ───────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockNavigate.mockReset();
|
||||
mockOverview.mockResolvedValue(
|
||||
OVERVIEW_OK as unknown as Awaited<ReturnType<typeof mockOverview>>
|
||||
);
|
||||
mockGroupsList.mockResolvedValue(GROUPS_OK);
|
||||
mockGraphqlJobs.mockResolvedValue(JOBS_OK);
|
||||
mockBountiesList.mockResolvedValue(
|
||||
BOUNTIES_OK as unknown as Awaited<ReturnType<typeof mockBountiesList>>
|
||||
);
|
||||
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 <h3>;
|
||||
// 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<typeof mockBountiesList>
|
||||
>);
|
||||
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<typeof mockBountiesList>
|
||||
>);
|
||||
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<ReturnType<typeof mockBountiesList>>);
|
||||
|
||||
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<ReturnType<typeof mockBountiesList>>);
|
||||
|
||||
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.
|
||||
});
|
||||
});
|
||||
@@ -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<StatsState>({ 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<T> =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ok'; data: T[] }
|
||||
| { status: 'empty' }
|
||||
| { status: 'error' };
|
||||
|
||||
// ── Communities hook ──────────────────────────────────────────────────────────
|
||||
|
||||
function useExploreCommunities(): SectionState<GroupMetadata> {
|
||||
const [state, setState] = useState<SectionState<GroupMetadata>>({ 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<GqlJobPosting> {
|
||||
const [state, setState] = useState<SectionState<GqlJobPosting>>({ 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<Bounty> {
|
||||
const [state, setState] = useState<SectionState<Bounty>>({ 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<AgentCard> {
|
||||
const [state, setState] = useState<SectionState<AgentCard>>({ 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 (
|
||||
<div className={`p-4 ${CARD_CLASS}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1.5 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="mt-0.5 text-xs text-stone-400 dark:text-neutral-500">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-center">
|
||||
<p className={`text-base font-medium ${tone}`}>{title}</p>
|
||||
{body && <p className="max-w-md text-sm text-stone-500 dark:text-neutral-400">{body}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section heading row with optional "View all" link. */
|
||||
function SectionHeader({
|
||||
title,
|
||||
viewAllLabel,
|
||||
onViewAll,
|
||||
}: {
|
||||
title: string;
|
||||
viewAllLabel: string;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-800 dark:text-neutral-200">{title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewAll}
|
||||
className="text-xs text-ocean-500 hover:underline dark:text-blue-400">
|
||||
{viewAllLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline empty message rendered inside a section when it has no data. */
|
||||
function SectionEmpty({ message }: { message: string }) {
|
||||
return <p className="py-4 text-center text-sm text-stone-400 dark:text-neutral-500">{message}</p>;
|
||||
}
|
||||
|
||||
// ── Communities section ───────────────────────────────────────────────────────
|
||||
|
||||
function CommunitySkeletonGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className={`animate-pulse p-3 ${CARD_CLASS}`}>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-full rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-1/3 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityCard({ group }: { group: GroupMetadata }) {
|
||||
const tags = group.tags ?? [];
|
||||
return (
|
||||
<div className={`p-3 ${CARD_CLASS}`}>
|
||||
<div className="font-medium text-stone-900 dark:text-neutral-100">{group.name}</div>
|
||||
{group.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{group.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-stone-400 dark:text-neutral-500">
|
||||
{group.memberCount} members
|
||||
</span>
|
||||
{tags.slice(0, 3).map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-stone-100 px-2 py-0.5 text-[11px] text-stone-600 dark:bg-neutral-800 dark:text-neutral-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExploreCommunitiesGrid({
|
||||
state,
|
||||
title,
|
||||
viewAllLabel,
|
||||
emptyMessage,
|
||||
onViewAll,
|
||||
}: {
|
||||
state: SectionState<GroupMetadata>;
|
||||
title: string;
|
||||
viewAllLabel: string;
|
||||
emptyMessage: string;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
if (state.status === 'error') return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title={title} viewAllLabel={viewAllLabel} onViewAll={onViewAll} />
|
||||
{state.status === 'loading' && <CommunitySkeletonGrid />}
|
||||
{state.status === 'empty' && <SectionEmpty message={emptyMessage} />}
|
||||
{state.status === 'ok' && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{state.data.map(group => (
|
||||
<CommunityCard key={group.groupId} group={group} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Jobs section ──────────────────────────────────────────────────────────────
|
||||
|
||||
function JobSkeletonList() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={`animate-pulse p-3 ${CARD_CLASS}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-2/3 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-1/3 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="h-5 w-16 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`p-3 ${CARD_CLASS}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-stone-900 dark:text-neutral-100">{job.title}</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{job.clientProfile.displayName} · {relativeTime(job.createdAt)}
|
||||
</div>
|
||||
{skills.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{skills.slice(0, 4).map(skill => (
|
||||
<span
|
||||
key={skill}
|
||||
className="rounded-full bg-stone-100 px-2 py-0.5 text-[11px] text-stone-600 dark:bg-neutral-800 dark:text-neutral-400">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<div className="text-sm font-semibold text-stone-800 dark:text-neutral-200">
|
||||
{job.budget.amount} {job.budget.asset}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExploreJobsList({
|
||||
state,
|
||||
title,
|
||||
viewAllLabel,
|
||||
emptyMessage,
|
||||
onViewAll,
|
||||
}: {
|
||||
state: SectionState<GqlJobPosting>;
|
||||
title: string;
|
||||
viewAllLabel: string;
|
||||
emptyMessage: string;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
if (state.status === 'error') return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title={title} viewAllLabel={viewAllLabel} onViewAll={onViewAll} />
|
||||
{state.status === 'loading' && <JobSkeletonList />}
|
||||
{state.status === 'empty' && <SectionEmpty message={emptyMessage} />}
|
||||
{state.status === 'ok' && (
|
||||
<div className="space-y-2">
|
||||
{state.data.map(job => (
|
||||
<JobRow key={job.jobId} job={job} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bounties section ──────────────────────────────────────────────────────────
|
||||
|
||||
function BountySkeletonList() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={`animate-pulse p-3 ${CARD_CLASS}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-2/3 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-1/4 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div className="h-5 w-20 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BountyRow({ bounty }: { bounty: Bounty }) {
|
||||
return (
|
||||
<div className={`p-3 ${CARD_CLASS}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-stone-900 dark:text-neutral-100">{bounty.title}</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{bounty.submissionCount} submission{bounty.submissionCount !== 1 ? 's' : ''}
|
||||
{bounty.deadline ? ` · deadline ${new Date(bounty.deadline).toLocaleDateString()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<div className="text-sm font-semibold text-stone-800 dark:text-neutral-200">
|
||||
{bounty.reward.amount} {bounty.reward.asset}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExploreBountiesList({
|
||||
state,
|
||||
title,
|
||||
viewAllLabel,
|
||||
emptyMessage,
|
||||
onViewAll,
|
||||
}: {
|
||||
state: SectionState<Bounty>;
|
||||
title: string;
|
||||
viewAllLabel: string;
|
||||
emptyMessage: string;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
if (state.status === 'error') return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title={title} viewAllLabel={viewAllLabel} onViewAll={onViewAll} />
|
||||
{state.status === 'loading' && <BountySkeletonList />}
|
||||
{state.status === 'empty' && <SectionEmpty message={emptyMessage} />}
|
||||
{state.status === 'ok' && (
|
||||
<div className="space-y-2">
|
||||
{state.data.map(bounty => (
|
||||
<BountyRow key={bounty.bountyId} bounty={bounty} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className={`animate-pulse p-3 ${CARD_CLASS}`}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="h-10 w-10 rounded-full bg-stone-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-16 rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-full rounded bg-stone-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`p-3 ${CARD_CLASS}`}>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full text-xs font-bold text-white ${colorClass}`}>
|
||||
{initials}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-stone-800 dark:text-neutral-200">{handle}</div>
|
||||
{agent.description && (
|
||||
<p className="line-clamp-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{agent.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExploreAgentsGrid({
|
||||
state,
|
||||
title,
|
||||
viewAllLabel,
|
||||
emptyMessage,
|
||||
onViewAll,
|
||||
}: {
|
||||
state: SectionState<AgentCard>;
|
||||
title: string;
|
||||
viewAllLabel: string;
|
||||
emptyMessage: string;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
if (state.status === 'error') return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title={title} viewAllLabel={viewAllLabel} onViewAll={onViewAll} />
|
||||
{state.status === 'loading' && <AgentSkeletonGrid />}
|
||||
{state.status === 'empty' && <SectionEmpty message={emptyMessage} />}
|
||||
{state.status === 'ok' && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{state.data.map(agent => (
|
||||
<AgentMiniCard key={agent.agentId} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Network stats section (existing, preserved) ───────────────────────────────
|
||||
|
||||
function NetworkStatsSection({ state }: { state: StatsState }) {
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center text-stone-400 dark:text-neutral-500">
|
||||
<span className="animate-pulse text-sm">Loading network overview…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'payment_required') {
|
||||
return (
|
||||
<StatusBlock
|
||||
tone="text-amber-600 dark:text-amber-400"
|
||||
title="Access requires payment"
|
||||
body="Your wallet will be used to fulfill the x402 payment challenge."
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
const isWalletLocked =
|
||||
state.message.includes('wallet is not configured') ||
|
||||
state.message.includes('wallet secret material is missing');
|
||||
return isWalletLocked ? (
|
||||
<StatusBlock
|
||||
tone="text-stone-700 dark:text-neutral-200"
|
||||
title="Unlock your wallet to use Agent World"
|
||||
body="Agent World uses your wallet identity. Import your recovery phrase in Settings to continue."
|
||||
/>
|
||||
) : (
|
||||
<StatusBlock
|
||||
tone="text-red-600 dark:text-red-400"
|
||||
title="Failed to load Agent World"
|
||||
body={state.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ov = state.data;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
All time
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Registered agents" value={num(ov.allTime?.registeredAgents)} />
|
||||
<StatCard label="Volume" value={usd(ov.allTime?.volumeUsd)} />
|
||||
<StatCard label="Fees" value={usd(ov.allTime?.feesUsd)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
Last 24 hours
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Transactions" value={num(ov.last24h?.transactions)} />
|
||||
<StatCard label="Active agents" value={num(ov.last24h?.uniqueAgents)} />
|
||||
<StatCard label="Volume" value={usd(ov.last24h?.volumeUsd)} />
|
||||
<StatCard label="Fees" value={usd(ov.last24h?.feesUsd)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
Ledger
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Total entries" value={num(ov.ledger?.totalEntries)} />
|
||||
<StatCard
|
||||
label="Latest tx"
|
||||
value={ov.ledger?.latestTxId ?? '—'}
|
||||
sub={
|
||||
ov.ledger?.latestTimestamp
|
||||
? new Date(ov.ledger.latestTimestamp).toLocaleString()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<PanelScaffold description={t('explore.networkOverview')}>
|
||||
<div className="space-y-8">
|
||||
{/* ── Network stats (top, always first) ── */}
|
||||
<section>
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
{t('explore.networkOverview')}
|
||||
</h3>
|
||||
<NetworkStatsSection state={statsState} />
|
||||
</section>
|
||||
|
||||
{/* ── Live data sections ── */}
|
||||
<ExploreCommunitiesGrid
|
||||
state={communitiesState}
|
||||
title={t('explore.trendingCommunities')}
|
||||
viewAllLabel={t('explore.viewAll')}
|
||||
emptyMessage={t('explore.noCommunities')}
|
||||
onViewAll={() => {
|
||||
// Navigate to Messaging (Groups tab) — no standalone communities route yet.
|
||||
navigate('/agent-world/messaging');
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExploreJobsList
|
||||
state={jobsState}
|
||||
title={t('explore.activeJobs')}
|
||||
viewAllLabel={t('explore.viewAll')}
|
||||
emptyMessage={t('explore.noJobs')}
|
||||
onViewAll={() => {
|
||||
navigate('/agent-world/jobs');
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExploreBountiesList
|
||||
state={bountiesState}
|
||||
title={t('explore.featuredBounties')}
|
||||
viewAllLabel={t('explore.viewAll')}
|
||||
emptyMessage={t('explore.noBounties')}
|
||||
onViewAll={() => {
|
||||
navigate('/agent-world/bounties');
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExploreAgentsGrid
|
||||
state={agentsState}
|
||||
title={t('explore.newAgents')}
|
||||
viewAllLabel={t('explore.viewAll')}
|
||||
emptyMessage={t('explore.noAgents')}
|
||||
onViewAll={() => {
|
||||
navigate('/agent-world/directory');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelScaffold>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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': 'الفواتير',
|
||||
|
||||
@@ -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': 'বিলিং',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'बिलिंग',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '결제',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Оплата',
|
||||
|
||||
@@ -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': '账单',
|
||||
|
||||
Reference in New Issue
Block a user