Skip to content
Open
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
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.3",
"@neondatabase/serverless": "catalog:prisma",
"@agentcash/router": "1.3.3",
"@ai-sdk/openai": "^2.0.52",
"@ai-sdk/react": "^2.0.68",
Expand Down
203 changes: 203 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,203 @@
'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,44 @@
'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,92 @@
'use client';

import {
createContext,
useCallback,
useContext,
useEffect,
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);

// Sync local state when URL search params change externally (e.g. home button click)
useEffect(() => {
const urlQuery = searchParams.get('q') ?? '';
if (urlQuery !== query) {
setQuery(urlQuery);
setInputRaw(urlQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);

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