Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions apps/scan/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"dependencies": {
"@agentcash/discovery": "1.6.1",
"@neondatabase/serverless": "catalog:prisma",
"@agentcash/router": "1.3.3",
"@ai-sdk/openai": "^2.0.52",
"@ai-sdk/react": "^2.0.68",
Expand Down
219 changes: 219 additions & 0 deletions apps/scan/src/app/(app)/(home)/(discover)/_components/columns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
'use client';

import {
Activity,
ArrowLeftRight,
Calendar,
DollarSign,
ExternalLink,
Globe,
Server,
Users,
} from 'lucide-react';

import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';

import {
KnownSellerChart,
LoadingKnownSellerChart,
} from '../../(overview)/_components/sellers/known-sellers/chart';

import { Origins, OriginsSkeleton } from '@/app/(app)/_components/origins';

import { formatCompactAgo } from '@/lib/utils';
import { formatTokenAmount } from '@/lib/token';

import type { ExtendedColumnDef } from '@/components/ui/data-table';
import type { RouterOutputs } from '@/trpc/client';
import { HeaderCell } from '@/components/ui/data-table/header-cell';
import { Chains } from '@/app/(app)/_components/chains';

import type { SearchResultEndpoint } from '@/lib/discover/search';

type BazaarItem =
RouterOutputs['public']['sellers']['bazaar']['list']['items'][number];

export type DiscoverColumnType = BazaarItem & {
searchEndpoint?: SearchResultEndpoint;
};

export const discoverColumns: ExtendedColumnDef<DiscoverColumnType>[] = [
{
accessorKey: 'recipients',
header: () => (
<HeaderCell Icon={Server} label="Server" className="mr-auto" />
),
cell: ({ row }) => {
const endpoint = row.original.searchEndpoint;

const originContent = (
<Origins
origins={row.original.origins}
addresses={row.original.recipients}
disableCopy
/>
);

const wrapped = endpoint?.summary ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">{originContent}</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p className="font-medium text-xs">
{endpoint.method} {endpoint.path}
</p>
<p className="text-xs text-muted-foreground">{endpoint.summary}</p>
</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center gap-2">{originContent}</div>
);

return wrapped;
},
size: 225,
loading: () => <OriginsSkeleton />,
},

{
accessorKey: 'chart',
header: () => (
<HeaderCell Icon={Activity} label="Activity" className="mx-auto" />
),
cell: ({ row }) =>
row.original.recipients.length > 0 ? (
<KnownSellerChart addresses={row.original.recipients} />
) : (
<div className="h-[32px]" />
),
size: 100,
loading: () => <LoadingKnownSellerChart />,
},
{
accessorKey: 'tx_count',
header: () => (
<HeaderCell
Icon={ArrowLeftRight}
label="Txns"
className="mx-auto"
/>
),
cell: ({ row }) => (
<div className="text-center font-mono text-xs">
{row.original.tx_count.toLocaleString(undefined, {
notation: 'compact',
maximumFractionDigits: 2,
minimumFractionDigits: 0,
})}
</div>
),
size: 100,
loading: () => <Skeleton className="h-4 w-16 mx-auto" />,
},
{
accessorKey: 'total_amount',
header: () => (
<HeaderCell
Icon={DollarSign}
label="Volume"
className="mx-auto"
/>
),
cell: ({ row }) => (
<div className="text-center font-mono text-xs">
{formatTokenAmount(BigInt(row.original.total_amount))}
</div>
),
size: 100,
loading: () => <Skeleton className="h-4 w-16 mx-auto" />,
},
{
accessorKey: 'unique_buyers',
header: () => (
<HeaderCell
Icon={Users}
label="Buyers"
className="mx-auto"
/>
),
cell: ({ row }) => (
<div className="text-center font-mono text-xs">
{row.original.unique_buyers.toLocaleString(undefined, {
notation: 'compact',
maximumFractionDigits: 2,
minimumFractionDigits: 0,
})}
</div>
),
size: 100,
loading: () => <Skeleton className="h-4 w-16 mx-auto" />,
},
{
accessorKey: 'latest_block_timestamp',
header: () => (
<HeaderCell
Icon={Calendar}
label="Latest"
className="mx-auto"
/>
),
cell: ({ row }) => (
<div className="text-center font-mono text-xs">
{row.original.latest_block_timestamp
? formatCompactAgo(row.original.latest_block_timestamp)
: '–'}
</div>
),
size: 100,
loading: () => <Skeleton className="h-4 w-16 mx-auto" />,
},
{
accessorKey: 'chains',
header: () => <HeaderCell Icon={Globe} label="Chain" className="mx-auto" />,
cell: ({ row }) => (
<Chains
chains={row.original.chains}
iconClassName="size-4"
className="mx-auto justify-center"
/>
),
size: 100,
loading: () => <Skeleton className="size-4 mx-auto" />,
},
{
accessorKey: 'tryIt',
header: () => (
<HeaderCell Icon={ExternalLink} label="Try It" className="mx-auto" />
),
cell: ({ row }) => {
const origin = row.original.origins[0]?.origin;
if (!origin) return null;
const stripped = origin.replace(/^https?:\/\//, '');
return (
<a
href={`https://tryponcho.com/p/${stripped}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors whitespace-nowrap"
onClick={e => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://tryponcho.com/favicon.svg"
alt="Poncho"
className="size-4"
/>
Try in Poncho
</a>
);
},
size: 130,
loading: () => <Skeleton className="h-4 w-24 mx-auto" />,
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client';

import { DataTable } from '@/components/ui/data-table';

import { useSellersSorting } from '@/app/(app)/_contexts/sorting/sellers/hook';
import { useTimeRangeContext } from '@/app/(app)/_contexts/time-range/hook';
import { useChain } from '@/app/(app)/_contexts/chain/hook';

import { discoverColumns as columns, type DiscoverColumnType } from './columns';
import { api } from '@/trpc/client';

interface Props {
originUrls: string[];
}

export const DiscoverSellersTable: React.FC<Props> = ({ originUrls }) => {
const { sorting } = useSellersSorting();
const { timeframe } = useTimeRangeContext();
const { chain } = useChain();

const [topSellers] = api.public.sellers.bazaar.list.useSuspenseQuery({
chain,
pagination: {
page_size: 100,
},
timeframe,
sorting,
originUrls,
});

return <DataTable columns={columns} data={topSellers.items as DiscoverColumnType[]} pageSize={15} />;
};

export const LoadingDiscoverSellersTable = () => {
return (
<DataTable columns={columns} data={[]} loadingRowCount={15} isLoading />
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';

import { useDiscoverSearch } from './discover-search-context';
import { DiscoverSearchResults } from './discover-search';

interface Props {
children: React.ReactNode;
}

/**
* Switches between default home content and search results.
*/
export const DiscoverPageContent = ({ children }: Props) => {
const { isSearching } = useDiscoverSearch();

if (isSearching) {
return <DiscoverSearchResults />;
}

return <>{children}</>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';

import { createContext, useCallback, useContext, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

interface DiscoverSearchContextValue {
input: string;
setInput: (value: string) => void;
query: string;
isSearching: boolean;
isDirty: boolean;
submit: () => void;
clear: () => void;
}

const DiscoverSearchContext = createContext<DiscoverSearchContextValue | null>(
null
);

export const useDiscoverSearch = () => {
const ctx = useContext(DiscoverSearchContext);
if (!ctx)
throw new Error(
'useDiscoverSearch must be used within DiscoverSearchProvider'
);
return ctx;
};

export const DiscoverSearchProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const router = useRouter();
const searchParams = useSearchParams();
const initialQuery = searchParams.get('q') ?? '';

const [input, setInputRaw] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);

const setInput = useCallback(
(value: string) => {
setInputRaw(value);
if (value.trim().length === 0 && query.length > 0) {
setQuery('');
router.replace('/', { scroll: false });
}
},
[query, router]
);

const submit = useCallback(() => {
const trimmed = input.trim();
if (trimmed.length > 0) {
setQuery(trimmed);
router.replace(`/?q=${encodeURIComponent(trimmed)}`, { scroll: false });
}
}, [input, router]);

const clear = useCallback(() => {
setInputRaw('');
setQuery('');
router.replace('/', { scroll: false });
}, [router]);

const isSearching = query.length > 0;
const isDirty = input.trim() !== query;

return (
<DiscoverSearchContext.Provider
value={{ input, setInput, query, isSearching, isDirty, submit, clear }}
>
{children}
</DiscoverSearchContext.Provider>
);
};
Loading
Loading