Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { useTranslation } from 'react-i18next'
import { NotFound } from '@/components/error/NotFound'
import { Card } from '@/components/ui/Card'
import type { AddressString } from '@/lib/schemas'
import { useAgentRegistration } from '@/services/agent-nft/hooks'
import { parseNftMetadataUri, useNftMetadata } from '@/services/nft-metadata'
import { useErc721Contract, useErc721Token } from '@/services/thor/tokens/erc721'
import { AgentNftView } from './agent/AgentNftView'
import { NftDetailHeader } from './NftDetailHeader'
import { NftStatsCards } from './NftStatsCards'
import { NftDetailsSection } from './NftDetailsSection'
Expand All @@ -27,8 +29,11 @@ export const NftDetailPageContent = ({
tokenId,
})
const { data: metadata, isPending: isMetadataPending } = useNftMetadata(token?.tokenUri ?? '')
const { data: agentRegistration, isPending: isAgentRegistrationPending } = useAgentRegistration({
tokenUri: token?.tokenUri,
})

const isPending = isCollectionPending || isTokenPending
const isPending = isCollectionPending || isTokenPending || (!!token?.tokenUri && isAgentRegistrationPending)

if (isPending) {
return (
Expand All @@ -45,6 +50,21 @@ export const NftDetailPageContent = ({
const nftImage = metadata?.image ? parseNftMetadataUri(metadata.image) : '/no-image.png'
const nftName = metadata?.name || `#${tokenId.toString()}`

// Agent NFTs (ERC-8004 registration file at tokenURI) get a dedicated tabbed view.
if (agentRegistration) {
const agentImage = agentRegistration.image ? parseNftMetadataUri(agentRegistration.image) : nftImage
return (
<AgentNftView
contractAddress={contractAddress}
tokenId={tokenId}
collection={collection}
registration={agentRegistration}
nftImage={agentImage}
tokenUri={token?.tokenUri}
/>
)
}

return (
<Card variant="primary">
<Flex alignItems="center" justifyContent="space-between" flexWrap="wrap">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client'

import { Box, EmptyState, Flex, Stack, Text } from '@chakra-ui/react'
import { LuInbox } from 'react-icons/lu'

/** Label/value row used across the agent tabs. Mirrors the NFT details layout. */
export const DetailRow = ({ label, children }: { label: string; children: React.ReactNode }) => (
<Flex
justifyContent="space-between"
alignItems="center"
py={4}
px={{ base: 4, md: 6 }}
borderBottomWidth="1px"
borderColor="border-primary"
_last={{ borderBottomWidth: 0 }}
gap={{ base: 2, md: 4 }}
>
<Text textStyle="bodyM" color="text-primary" minWidth={{ base: '80px', md: '120px' }} flexShrink={0}>
{label}
</Text>
<Box overflow="hidden" textAlign="right" minWidth={0}>
{children}
</Box>
</Flex>
)

/** Titled, bordered container grouping a set of rows. */
export const SectionCard = ({ title, children }: { title: string; children: React.ReactNode }) => (
<Stack gap={5}>
<Text textStyle="bodyL" color="text-primary">
{title}
</Text>
<Box borderWidth="1px" borderColor="border-primary" borderRadius="lg" overflow="hidden">
{children}
</Box>
</Stack>
)

/** Shared empty-state placeholder for reputation-backed tabs with no data yet. */
export const AgentEmptyState = ({ title, description }: { title: string; description: string }) => (
<EmptyState.Root size="md">
<EmptyState.Content>
<EmptyState.Indicator>
<LuInbox />
</EmptyState.Indicator>
<EmptyState.Title>{title}</EmptyState.Title>
<EmptyState.Description>{description}</EmptyState.Description>
</EmptyState.Content>
</EmptyState.Root>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
'use client'

import { Badge, Flex, Grid, Heading, Stack, Tabs } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { Card } from '@/components/ui/Card'
import type { AddressString } from '@/lib/schemas'
import { useAgentCard } from '@/services/agent-nft/hooks'
import { type AgentRegistration, getAgentCardEndpoint } from '@/services/agent-nft/schemas'
import type { Erc721 } from '@/services/thor/tokens/erc721'
import { useAgentInfo } from '@/services/thor/tokens/agent-registry'
import { useAgentReputation } from '@/services/thor/tokens/reputation-registry'
import { NftDetailHeader } from '../NftDetailHeader'
import { NftTransfersSection } from '../NftTransfersSection'
import { OverviewTab } from './tabs/OverviewTab'
import { ServicesTab } from './tabs/ServicesTab'
import { StatisticsTab } from './tabs/StatisticsTab'
import { QualityTab } from './tabs/QualityTab'
import { FeedbackTab } from './tabs/FeedbackTab'
import { MetadataTab } from './tabs/MetadataTab'

interface AgentNftViewProps {
contractAddress: AddressString
tokenId: bigint
collection: Erc721
registration: AgentRegistration
nftImage: string
tokenUri: string | undefined
}

export const AgentNftView = ({
contractAddress,
tokenId,
collection,
registration,
nftImage,
tokenUri,
}: AgentNftViewProps) => {
const { t } = useTranslation()

const nftName = registration.name || collection.name || `#${tokenId.toString()}`
// The agent NFT contract is itself the ERC-8004 AgentRegistry (tokenId == agentId).
const { data: agentInfo, isPending: isAgentInfoPending } = useAgentInfo({ contractAddress, agentId: tokenId })

const reputationAddress = registration.reputationRegistry?.address ?? null
const { data: reputation, isPending: isReputationPending } = useAgentReputation({
reputationAddress,
agentId: tokenId,
})
const hasReputationRegistry = !!reputationAddress

const agentCardEndpoint = getAgentCardEndpoint(registration)
const { data: agentCard, isPending: isAgentCardPending } = useAgentCard({ endpoint: agentCardEndpoint })

return (
<Card variant="primary">
<Flex alignItems="center" gap={3} flexWrap="wrap">
<Heading as="h2" textStyle="displayXs" whiteSpace="nowrap">
{t('AI Agent')}
</Heading>
<Badge colorPalette="purple">{t('Agent')}</Badge>
</Flex>

<Grid templateColumns={{ base: '1fr', lg: '322px 1fr' }} gap={5} alignItems="start">
<NftDetailHeader
nftImage={nftImage}
nftName={nftName}
tokenId={tokenId}
collection={collection}
description={registration.description ?? undefined}
/>

<Stack gap={4} flex={1} minWidth={0}>
<Tabs.Root defaultValue="overview" variant="line" lazyMount unmountOnExit>
<Tabs.List overflowX="auto" flexWrap="nowrap">
<Tabs.Trigger value="overview">{t('Overview')}</Tabs.Trigger>
<Tabs.Trigger value="services">{t('Services')}</Tabs.Trigger>
<Tabs.Trigger value="statistics">{t('Statistics')}</Tabs.Trigger>
<Tabs.Trigger value="quality">{t('Quality')}</Tabs.Trigger>
<Tabs.Trigger value="feedback">{t('Feedback')}</Tabs.Trigger>
<Tabs.Trigger value="metadata">{t('Metadata')}</Tabs.Trigger>
</Tabs.List>

<Tabs.Content value="overview">
<OverviewTab registration={registration} agentInfo={agentInfo} isAgentInfoPending={isAgentInfoPending} />
</Tabs.Content>
<Tabs.Content value="services">
<ServicesTab registration={registration} agentCard={agentCard} isAgentCardPending={isAgentCardPending} />
</Tabs.Content>
<Tabs.Content value="statistics">
<StatisticsTab
reputation={reputation}
isPending={isReputationPending}
hasReputationRegistry={hasReputationRegistry}
/>
</Tabs.Content>
<Tabs.Content value="quality">
<QualityTab
reputation={reputation}
isPending={isReputationPending}
hasReputationRegistry={hasReputationRegistry}
/>
</Tabs.Content>
<Tabs.Content value="feedback">
<FeedbackTab
reputation={reputation}
isPending={isReputationPending}
hasReputationRegistry={hasReputationRegistry}
/>
</Tabs.Content>
<Tabs.Content value="metadata">
<MetadataTab
registration={registration}
contractAddress={contractAddress}
tokenId={tokenId}
tokenUri={tokenUri}
/>
</Tabs.Content>
</Tabs.Root>
</Stack>
</Grid>

<NftTransfersSection contractAddress={contractAddress} tokenId={tokenId} />
</Card>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use client'

import { Badge, Skeleton, Text } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { CopyableAddressLink } from '@/components/ui/Links'
import { type Column, DataTable, type TableRow } from '@/components/ui/Table'
import type { AddressString } from '@/lib/schemas'
import type { AgentReputation } from '@/services/thor/tokens/reputation-registry'
import { AgentEmptyState } from '../AgentDetail'

interface FeedbackTabProps {
reputation: AgentReputation | undefined
isPending: boolean
hasReputationRegistry: boolean
}

interface FeedbackRow extends TableRow {
id: string
client: string
value: string
status: string
}

export const FeedbackTab = ({ reputation, isPending, hasReputationRegistry }: FeedbackTabProps) => {
const { t } = useTranslation()

if (!hasReputationRegistry) {
return (
<AgentEmptyState
title={t('No feedback available')}
description={t('This agent does not publish an on-chain reputation registry')}
/>
)
}

if (isPending) {
return <Skeleton height="120px" borderRadius="md" />
}

if (!reputation || reputation.feedback.length === 0) {
return (
<AgentEmptyState title={t('No feedback yet')} description={t('This agent has not received any feedback yet')} />
)
}

const columns: Column<FeedbackRow>[] = [
{
key: 'client',
label: t('Client'),
Cell: ({ value }) => <CopyableAddressLink truncate address={value as AddressString} />,
},
{
key: 'value',
label: t('Rating'),
Cell: ({ value }) => (
<Text textStyle="bodyM" color="text-primary">
{String(value)}
</Text>
),
},
{
key: 'status',
label: t('Status'),
Cell: ({ value }) => (
<Badge colorPalette={value === 'revoked' ? 'red' : 'green'}>
{value === 'revoked' ? t('Revoked') : t('Active')}
</Badge>
),
},
]

const rows: FeedbackRow[] = reputation.feedback.map(entry => ({
id: `${entry.client}-${entry.index}`,
client: entry.client,
value: String(entry.value),
status: entry.revoked ? 'revoked' : 'active',
}))

return <DataTable columns={columns} rows={rows} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client'

import { Box, Stack, Text } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { CopyableAddressLink } from '@/components/ui/Links'
import type { AddressString } from '@/lib/schemas'
import { type AgentRegistration, parseCaipAddress } from '@/services/agent-nft/schemas'
import { DetailRow, SectionCard } from '../AgentDetail'

interface MetadataTabProps {
registration: AgentRegistration
contractAddress: AddressString
tokenId: bigint
tokenUri: string | undefined
}

export const MetadataTab = ({ registration, contractAddress, tokenId, tokenUri }: MetadataTabProps) => {
const { t } = useTranslation()
const reputationAddress = registration.reputationRegistry?.address
// Prefer the registry from the metadata's CAIP-10 reference; fall back to the NFT contract.
const registryAddress = parseCaipAddress(registration.registrations[0]?.agentRegistry) ?? contractAddress

return (
<Stack gap={6}>
<SectionCard title={t('On-chain Identity')}>
<DetailRow label={t('Token ID')}>
<Text textStyle="bodyM" color="text-primary">
#{tokenId.toString()}
</Text>
</DetailRow>
<DetailRow label={t('Agent Registry')}>
<CopyableAddressLink address={registryAddress} truncate />
</DetailRow>
{reputationAddress && (
<DetailRow label={t('Reputation Registry')}>
<CopyableAddressLink address={reputationAddress as AddressString} truncate />
</DetailRow>
)}
<DetailRow label={t('Schema')}>
<Text textStyle="bodyS" color="text-primary" wordBreak="break-all">
{registration.type}
</Text>
</DetailRow>
</SectionCard>

<SectionCard title={t('Raw Metadata')}>
<Box px={{ base: 4, md: 6 }} py={4}>
{tokenUri && (
<Text textStyle="bodyS" color="text-secondary" mb={3} wordBreak="break-all">
{tokenUri}
</Text>
)}
<Box as="pre" bg="bg-alt-primary" borderRadius="md" p={4} overflowX="auto" fontSize="xs" color="text-primary">
{JSON.stringify(registration, null, 2)}
</Box>
</Box>
</SectionCard>
</Stack>
)
}
Loading
Loading