From b41bc2e1d20e1d6abfbe3d6c6ccd92cb67d62ee1 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 27 Mar 2026 22:32:39 +0400 Subject: [PATCH] feat: add unified search to trends, posts, users --- .../trending/__tests__/trendsSearch.test.ts | 118 +++ src/features/trending/api/trendsSearch.ts | 193 ++++ src/features/trending/views/TokenList.tsx | 838 ++++++++++++++---- .../views/__tests__/TokenList.test.tsx | 288 ++++++ 4 files changed, 1269 insertions(+), 168 deletions(-) create mode 100644 src/features/trending/__tests__/trendsSearch.test.ts create mode 100644 src/features/trending/api/trendsSearch.ts create mode 100644 src/features/trending/views/__tests__/TokenList.test.tsx diff --git a/src/features/trending/__tests__/trendsSearch.test.ts b/src/features/trending/__tests__/trendsSearch.test.ts new file mode 100644 index 000000000..d0e9b10dc --- /dev/null +++ b/src/features/trending/__tests__/trendsSearch.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable object-curly-newline */ +import { + describe, expect, it, vi, +} from 'vitest'; +import { SuperheroApi } from '@/api/backend'; +import { + fetchPopularPosts, + fetchTopTraders, + fetchTrendingTokens, + fetchTrendSearchPreview, + fetchTrendSearchSection, +} from '../api/trendsSearch'; +import { fetchLeaderboard } from '../api/leaderboard'; + +vi.mock('@/api/backend', () => ({ + SuperheroApi: { + fetchJson: vi.fn(), + listTokens: vi.fn(), + listPosts: vi.fn(), + listPopularPosts: vi.fn(), + }, +})); + +vi.mock('../api/leaderboard', () => ({ + fetchLeaderboard: vi.fn(), +})); + +describe('trendsSearch api helpers', () => { + it('loads preview results from all three search endpoints', async () => { + vi.mocked(SuperheroApi.listTokens).mockResolvedValueOnce({ + items: [{ address: 'ct_token', sale_address: 'ct_sale', name: 'HELLO' }], + meta: { totalItems: 8, totalPages: 3, currentPage: 1 }, + } as any); + vi.mocked(SuperheroApi.fetchJson).mockResolvedValueOnce({ + items: [{ address: 'ak_user', chain_name: 'hello.chain' }], + meta: { totalItems: 5, totalPages: 2, currentPage: 1 }, + } as any); + vi.mocked(SuperheroApi.listPosts).mockResolvedValueOnce({ + items: [{ id: 'post_1_v3', sender_address: 'ak_user', content: 'hello post', media: [], topics: [], total_comments: 0, tx_hash: '', tx_args: [], contract_address: '', type: 'post', created_at: '2026-03-27T12:00:00.000Z' }], + meta: { totalItems: 4, totalPages: 2, currentPage: 1 }, + } as any); + + const result = await fetchTrendSearchPreview('hello'); + + expect(SuperheroApi.listTokens).toHaveBeenCalledWith({ + search: 'hello', + limit: 3, + page: 1, + orderBy: 'market_cap', + orderDirection: 'DESC', + }); + expect(SuperheroApi.fetchJson).toHaveBeenCalledWith('/api/accounts?limit=3&search=hello'); + expect(SuperheroApi.listPosts).toHaveBeenCalledWith({ + search: 'hello', + limit: 3, + page: 1, + orderBy: 'created_at', + orderDirection: 'DESC', + }); + expect(result.tokens.meta.totalItems).toBe(8); + expect(result.users.items[0].address).toBe('ak_user'); + expect(result.posts.items[0].id).toBe('post_1_v3'); + }); + + it('loads a full section result set for users', async () => { + vi.mocked(SuperheroApi.fetchJson).mockResolvedValueOnce({ + items: [{ address: 'ak_full', chain_name: 'full.chain' }], + meta: { totalItems: 14, totalPages: 1, currentPage: 1 }, + } as any); + + const result = await fetchTrendSearchSection('users', 'full'); + + expect(SuperheroApi.fetchJson).toHaveBeenCalledWith('/api/accounts?limit=24&search=full'); + expect(result.meta.totalItems).toBe(14); + expect(result.items[0].address).toBe('ak_full'); + }); + + it('loads fallback content for tokens, posts and traders', async () => { + vi.mocked(SuperheroApi.listTokens).mockResolvedValueOnce({ + items: [{ address: 'ct_fallback', sale_address: 'ct_fallback_sale', name: 'TREND' }], + } as any); + vi.mocked(SuperheroApi.listPopularPosts).mockResolvedValueOnce({ + items: [{ id: 'popular_v3', sender_address: 'ak_popular', content: 'popular', media: [], topics: [], total_comments: 2, tx_hash: '', tx_args: [], contract_address: '', type: 'post', created_at: '2026-03-27T12:00:00.000Z' }], + } as any); + vi.mocked(fetchLeaderboard).mockResolvedValueOnce({ + items: [{ address: 'ak_trader', pnl_usd: 1200 }], + meta: { totalItems: 1, totalPages: 1, currentPage: 1 }, + }); + + const [tokens, posts, traders] = await Promise.all([ + fetchTrendingTokens(3), + fetchPopularPosts(3), + fetchTopTraders(3), + ]); + + expect(SuperheroApi.listTokens).toHaveBeenCalledWith({ + limit: 3, + page: 1, + orderBy: 'trending_score', + orderDirection: 'DESC', + }); + expect(SuperheroApi.listPopularPosts).toHaveBeenCalledWith({ + window: 'all', + limit: 3, + page: 1, + }); + expect(fetchLeaderboard).toHaveBeenCalledWith({ + timeframe: '7d', + metric: 'pnl', + page: 1, + limit: 3, + sortDir: 'DESC', + }); + expect(tokens.items[0].name).toBe('TREND'); + expect(posts.items[0].id).toBe('popular_v3'); + expect(traders.items[0].address).toBe('ak_trader'); + }); +}); diff --git a/src/features/trending/api/trendsSearch.ts b/src/features/trending/api/trendsSearch.ts new file mode 100644 index 000000000..0e9b6feeb --- /dev/null +++ b/src/features/trending/api/trendsSearch.ts @@ -0,0 +1,193 @@ +import type { PostDto, TokenDto } from '@/api/generated'; +import { SuperheroApi } from '@/api/backend'; +import { + fetchLeaderboard, + type LeaderboardItem, +} from './leaderboard'; + +export type SearchTab = 'tokens' | 'users' | 'posts'; + +export type SearchMeta = { + totalItems: number; + totalPages: number; + currentPage: number; +}; + +export type SearchSection = { + items: T[]; + meta: SearchMeta; +}; + +export type TrendTokenItem = TokenDto; + +export type TrendUserItem = { + address: string; + bio?: string | null; + chain_name?: string | null; + chain_name_updated_at?: string | null; + total_volume?: string | number | null; + total_tx_count?: number | null; + total_buy_tx_count?: number | null; + total_sell_tx_count?: number | null; + total_created_tokens?: number | null; + created_at?: string | null; +}; + +export type TrendPostItem = PostDto & { + slug?: string | null; + sender?: { + address?: string; + public_name?: string | null; + bio?: string | null; + avatarurl?: string | null; + } | null; + token_mentions?: string[]; +}; + +export const SEARCH_PREVIEW_LIMIT = 3; +export const SEARCH_FULL_LIMIT = 24; +export const DEFAULT_TAB_LIMIT = 12; +export const FALLBACK_LIMIT = 3; + +type PaginatedApiResponse = { + items?: T[]; + meta?: { + totalItems?: number; + totalPages?: number; + currentPage?: number; + page?: number; + }; +}; + +function normalizeSection( + response: PaginatedApiResponse | T[] | null | undefined, +): SearchSection { + if (Array.isArray(response)) { + return { + items: response, + meta: { + totalItems: response.length, + totalPages: 1, + currentPage: 1, + }, + }; + } + + const items = response?.items ?? []; + const totalItems = response?.meta?.totalItems ?? items.length; + const totalPages = response?.meta?.totalPages ?? 1; + const currentPage = response?.meta?.currentPage ?? response?.meta?.page ?? 1; + + return { + items, + meta: { + totalItems, + totalPages, + currentPage, + }, + }; +} + +async function fetchAccountSearch(limit: number, search?: string) { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + + if (search?.trim()) { + params.set('search', search.trim()); + } + + const suffix = params.toString(); + return SuperheroApi.fetchJson(`/api/accounts${suffix ? `?${suffix}` : ''}`) as Promise< + PaginatedApiResponse + >; +} + +function settledValue( + result: PromiseSettledResult, +): T | undefined { + return result.status === 'fulfilled' ? result.value : undefined; +} + +export async function fetchTrendSearchPreview(search: string) { + const term = search.trim(); + + const [tokens, users, posts] = await Promise.allSettled([ + SuperheroApi.listTokens({ + search: term, + limit: SEARCH_PREVIEW_LIMIT, + page: 1, + orderBy: 'market_cap', + orderDirection: 'DESC', + }) as Promise>, + fetchAccountSearch(SEARCH_PREVIEW_LIMIT, term), + SuperheroApi.listPosts({ + search: term, + limit: SEARCH_PREVIEW_LIMIT, + page: 1, + orderBy: 'created_at', + orderDirection: 'DESC', + }) as Promise>, + ]); + + return { + tokens: normalizeSection(settledValue(tokens)), + users: normalizeSection(settledValue(users)), + posts: normalizeSection(settledValue(posts)), + }; +} + +export async function fetchTrendSearchSection(tab: SearchTab, search: string) { + const term = search.trim(); + + switch (tab) { + case 'tokens': + return normalizeSection(await SuperheroApi.listTokens({ + search: term, + limit: SEARCH_FULL_LIMIT, + page: 1, + orderBy: 'market_cap', + orderDirection: 'DESC', + }) as PaginatedApiResponse); + case 'users': + return normalizeSection(await fetchAccountSearch(SEARCH_FULL_LIMIT, term)); + case 'posts': + return normalizeSection(await SuperheroApi.listPosts({ + search: term, + limit: SEARCH_FULL_LIMIT, + page: 1, + orderBy: 'created_at', + orderDirection: 'DESC', + }) as PaginatedApiResponse); + default: { + const exhaustive: never = tab; + throw new Error(`Unknown search tab: ${exhaustive}`); + } + } +} + +export async function fetchTrendingTokens(limit: number = DEFAULT_TAB_LIMIT) { + return normalizeSection(await SuperheroApi.listTokens({ + limit, + page: 1, + orderBy: 'trending_score', + orderDirection: 'DESC', + }) as PaginatedApiResponse); +} + +export async function fetchPopularPosts(limit: number = DEFAULT_TAB_LIMIT) { + return normalizeSection(await SuperheroApi.listPopularPosts({ + window: 'all', + limit, + page: 1, + }) as PaginatedApiResponse); +} + +export async function fetchTopTraders(limit: number = DEFAULT_TAB_LIMIT) { + return fetchLeaderboard({ + timeframe: '7d', + metric: 'pnl', + page: 1, + limit, + sortDir: 'DESC', + }) as Promise>; +} diff --git a/src/features/trending/views/TokenList.tsx b/src/features/trending/views/TokenList.tsx index becd2af52..aa8c548a7 100644 --- a/src/features/trending/views/TokenList.tsx +++ b/src/features/trending/views/TokenList.tsx @@ -1,8 +1,13 @@ +import { Encoding, isEncoded } from '@aeternity/aepp-sdk'; import Spinner from '@/components/Spinner'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import AddressAvatar from '@/components/AddressAvatar'; +import { Input } from '@/components/ui/input'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { Search as SearchIcon } from 'lucide-react'; import { - useEffect, useMemo, useRef, useState, + useCallback, useEffect, useMemo, useRef, useState, } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; import { TokensService } from '../../../api/generated'; import LatestTransactionsCarousel from '../../../components/Trendminer/LatestTransactionsCarousel'; import { @@ -13,12 +18,29 @@ import { SelectValue, } from '../../../components/ui/select'; import { Head } from '../../../seo/Head'; +import { formatAddress } from '../../../utils/address'; +import { formatCompactNumber } from '../../../utils/number'; +import { + DEFAULT_TAB_LIMIT, + FALLBACK_LIMIT, + SEARCH_PREVIEW_LIMIT, + fetchPopularPosts, + fetchTopTraders, + fetchTrendingTokens, + fetchTrendSearchPreview, + fetchTrendSearchSection, + type SearchSection, + type SearchTab, + type TrendPostItem, + type TrendTokenItem, + type TrendUserItem, +} from '../api/trendsSearch'; +import type { LeaderboardItem } from '../api/leaderboard'; import TokenListTable from '../components/TokenListTable'; -import TrendminerBanner from '../components/TrendminerBanner'; +import ReplyToFeedItem from '../../social/components/ReplyToFeedItem'; type SelectOptions = Array<{ title: string; - disabled?: boolean; value: T; }>; @@ -32,244 +54,724 @@ const SORT = { price: 'price', } as const; +const SEARCH_TABS: SearchTab[] = ['tokens', 'users', 'posts']; + +const TAB_LABELS: Record = { + tokens: 'Tokens', + users: 'Users', + posts: 'Posts', +}; + type OrderByOption = typeof SORT[keyof typeof SORT]; -type CollectionOption = 'all' | string; // Can be 'all' or specific collection addresses + +const NO_GRADIENT_STYLE: React.CSSProperties = { + color: 'var(--standard-font-color)', + WebkitTextFillColor: 'var(--standard-font-color)', + background: 'none', + WebkitBackgroundClip: 'initial', + backgroundClip: 'initial', +}; + +const ORDER_BY_OPTIONS: SelectOptions = [ + { title: 'Market Cap', value: SORT.marketCap }, + { title: 'Trending', value: SORT.trendingScore }, + { title: 'Price', value: SORT.price }, + { title: 'Name', value: SORT.name }, + { title: 'Newest', value: SORT.newest }, + { title: 'Oldest', value: SORT.oldest }, + { title: 'Holders Count', value: SORT.holdersCount }, +]; + +function isLeaderboardItem(item: TrendUserItem | LeaderboardItem): item is LeaderboardItem { + return 'pnl_usd' in item || 'aum_usd' in item || 'roi_pct' in item || 'mdd_pct' in item; +} + +function getFallbackSubtitle(tab: SearchTab) { + if (tab === 'tokens') { + return 'No matching tokens found. Showing trending tokens instead.'; + } + + if (tab === 'users') { + return 'No matching users found. Showing top traders instead.'; + } + + return 'No matching posts found. Showing popular posts instead.'; +} + +const SearchSectionShell = ({ + title, + subtitle, + children, + footer, + contentClassName, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; + footer?: React.ReactNode; + contentClassName?: string; +}) => ( +
+
+

+ {title} +

+ {subtitle ?

{subtitle}

: null} +
+
{children}
+ {footer ?
{footer}
: null} +
+); + +const EmptyPanel = ({ message }: { message: string }) => ( +
+ {message} +
+); + +const InlineLoading = ({ label = 'Loading...' }: { label?: string }) => ( +
+ + {label} +
+); + +const TokenResultsList = ({ items }: { items: TrendTokenItem[] }) => ( + {}} + /> +); + +const UserStatsCell = ({ label, value }: { label: string; value: string }) => ( +
+
{label}
+
{value}
+
+); + +function getUserStats(item: TrendUserItem | LeaderboardItem) { + if (isLeaderboardItem(item)) { + return [ + { label: 'PnL', value: `$${formatCompactNumber(item.pnl_usd, 2, 1)}` }, + { label: 'ROI', value: `${formatCompactNumber(item.roi_pct, 2, 1)}%` }, + { label: 'AUM', value: `$${formatCompactNumber(item.aum_usd, 2, 1)}` }, + ]; + } + + return [ + { label: 'Volume', value: `${formatCompactNumber(item.total_volume, 2, 1)} AE` }, + { label: 'Txs', value: formatCompactNumber(item.total_tx_count, 0, 1) }, + { label: 'Created', value: formatCompactNumber(item.total_created_tokens, 0, 1) }, + ]; +} + +const UserResultsList = ({ items }: { items: Array }) => ( + <> + {items.map((item) => { + const { address } = item; + const title = item.chain_name || formatAddress(address, 6); + const stats = getUserStats(item); + + return ( + +
+ +
+
{title}
+
+ {formatAddress(address, 10, false)} +
+
+
+
+ {stats.map((s) => ( + + ))} +
+ + ); + })} + +); + +const PostResultsList = ({ + items, + onOpenPost, +}: { + items: TrendPostItem[]; + onOpenPost: (slugOrId: string) => void; +}) => ( + <> + {items.map((post) => ( + + ))} + +); const TokenList = () => { - const [collection] = useState('all'); + const navigate = useNavigate(); const [orderBy, setOrderBy] = useState(SORT.trendingScore); const [orderDirection, setOrderDirection] = useState<'ASC' | 'DESC'>('DESC'); - const [search, setSearch] = useState(''); - const [searchThrottled, setSearchThrottled] = useState(''); + const [activeTab, setActiveTab] = useState('tokens'); + const [searchInput, setSearchInput] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [expandedSections, setExpandedSections] = useState>({ + tokens: false, + users: false, + posts: false, + }); const loadMoreBtn = useRef(null); - // Throttle search input (2000ms delay like Vue) useEffect(() => { - const timeoutId = setTimeout(() => { - setSearchThrottled(search); - }, 2000); - - return () => clearTimeout(timeoutId); - }, [search]); - - const orderByOptions: SelectOptions = [ - { - title: 'Market Cap', - value: SORT.marketCap, - }, - { - title: 'Trending', - value: SORT.trendingScore, - }, - { - title: 'Price', - value: SORT.price, - }, - { - title: 'Name', - value: SORT.name, - }, - { - title: 'Newest', - value: SORT.newest, - }, - { - title: 'Oldest', - value: SORT.oldest, - }, - { - title: 'Holders Count', - value: SORT.holdersCount, - }, - ]; + const timeoutId = window.setTimeout(() => { + setSearchTerm(searchInput.trim()); + }, 350); + + return () => window.clearTimeout(timeoutId); + }, [searchInput]); - // Remove hardcoded collection options - these should be dynamic based on available collections - // For now, just use 'all' as the Vue implementation shows collection can be any string + useEffect(() => { + setExpandedSections({ + tokens: false, + users: false, + posts: false, + }); + }, [searchTerm]); + + const hasSearch = searchTerm.length > 0; + + const handleOpenPost = useCallback( + (slugOrId: string) => navigate(`/post/${encodeURIComponent(slugOrId)}`), + [navigate], + ); const orderByMapped = useMemo(() => { if (orderBy === SORT.newest || orderBy === SORT.oldest) { return 'created_at'; } + return orderBy; }, [orderBy]); const finalOrderDirection = useMemo((): 'ASC' | 'DESC' => { - // For date-based sorting, override the direction if (orderBy === SORT.oldest) return 'ASC'; if (orderBy === SORT.newest) return 'DESC'; - // For other fields, use the state return orderDirection; }, [orderBy, orderDirection]); const { - data, isFetching, fetchNextPage, hasNextPage, + data: tokenPages, + isFetching: isFetchingTokens, + fetchNextPage, + hasNextPage, } = useInfiniteQuery({ + enabled: !hasSearch && activeTab === 'tokens', initialPageParam: 1, queryFn: ({ pageParam = 1 }) => TokensService.listAll({ orderBy: orderByMapped as any, orderDirection: finalOrderDirection, - collection: collection === 'all' ? undefined : (collection as any), - search: searchThrottled || undefined, limit: 20, page: pageParam, }), - getNextPageParam: ( - lastPage: any, - allPages, - lastPageParam, - ) => (lastPage?.meta?.currentPage === lastPage?.meta?.totalPages - ? undefined - : lastPageParam + 1), + getNextPageParam: (lastPage: any, _allPages, lastPageParam) => ( + lastPage?.meta?.currentPage === lastPage?.meta?.totalPages + ? undefined + : lastPageParam + 1 + ), queryKey: [ 'TokensService.listAll', orderBy, orderByMapped, finalOrderDirection, - collection, - searchThrottled, + activeTab, + hasSearch, ], - staleTime: 1000 * 60, // 1 minute + staleTime: 60 * 1000, + }); + + useEffect(() => { + if (hasSearch || activeTab !== 'tokens') { + return undefined; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.intersectionRatio === 1 && hasNextPage && !isFetchingTokens) { + fetchNextPage(); + } + }, + { threshold: 1 }, + ); + + if (loadMoreBtn.current) { + observer.observe(loadMoreBtn.current); + } + + return () => observer.disconnect(); + }, [activeTab, fetchNextPage, hasNextPage, hasSearch, isFetchingTokens]); + + const usersTabQuery = useQuery({ + enabled: !hasSearch && activeTab === 'users', + queryKey: ['trends', 'top-traders', DEFAULT_TAB_LIMIT], + queryFn: () => fetchTopTraders(DEFAULT_TAB_LIMIT), + staleTime: 60 * 1000, + }); + + const postsTabQuery = useQuery({ + enabled: !hasSearch && activeTab === 'posts', + queryKey: ['trends', 'popular-posts', DEFAULT_TAB_LIMIT], + queryFn: () => fetchPopularPosts(DEFAULT_TAB_LIMIT), + staleTime: 60 * 1000, + }); + + const searchPreviewQuery = useQuery({ + enabled: hasSearch, + queryKey: ['trends', 'search-preview', searchTerm], + queryFn: () => fetchTrendSearchPreview(searchTerm), + staleTime: 30 * 1000, + retry: 1, + }); + + const expandedTokenQuery = useQuery({ + enabled: hasSearch + && expandedSections.tokens + && (searchPreviewQuery.data?.tokens.meta.totalItems ?? 0) > SEARCH_PREVIEW_LIMIT, + queryKey: ['trends', 'search-section', 'tokens', searchTerm], + queryFn: () => fetchTrendSearchSection('tokens', searchTerm), + staleTime: 30 * 1000, + }); + + const expandedUsersQuery = useQuery({ + enabled: hasSearch + && expandedSections.users + && (searchPreviewQuery.data?.users.meta.totalItems ?? 0) > SEARCH_PREVIEW_LIMIT, + queryKey: ['trends', 'search-section', 'users', searchTerm], + queryFn: () => fetchTrendSearchSection('users', searchTerm), + staleTime: 30 * 1000, }); + const expandedPostsQuery = useQuery({ + enabled: hasSearch + && expandedSections.posts + && (searchPreviewQuery.data?.posts.meta.totalItems ?? 0) > SEARCH_PREVIEW_LIMIT, + queryKey: ['trends', 'search-section', 'posts', searchTerm], + queryFn: () => fetchTrendSearchSection('posts', searchTerm), + staleTime: 30 * 1000, + }); + + const fallbackTokensQuery = useQuery({ + enabled: hasSearch + && searchPreviewQuery.isSuccess + && searchPreviewQuery.data.tokens.items.length === 0, + queryKey: ['trends', 'fallback', 'tokens', FALLBACK_LIMIT], + queryFn: () => fetchTrendingTokens(FALLBACK_LIMIT), + staleTime: 60 * 1000, + }); + + const fallbackUsersQuery = useQuery({ + enabled: hasSearch + && searchPreviewQuery.isSuccess + && searchPreviewQuery.data.users.items.length === 0, + queryKey: ['trends', 'fallback', 'users', FALLBACK_LIMIT], + queryFn: () => fetchTopTraders(FALLBACK_LIMIT), + staleTime: 60 * 1000, + }); + + const fallbackPostsQuery = useQuery({ + enabled: hasSearch + && searchPreviewQuery.isSuccess + && searchPreviewQuery.data.posts.items.length === 0, + queryKey: ['trends', 'fallback', 'posts', FALLBACK_LIMIT], + queryFn: () => fetchPopularPosts(FALLBACK_LIMIT), + staleTime: 60 * 1000, + }); + + const searchOrder = useMemo(() => { + const preview = searchPreviewQuery.data; + if (!preview) { + return [activeTab, ...SEARCH_TABS.filter((t) => t !== activeTab)]; + } + + const counts: Record = { + tokens: preview.tokens.items.length, + users: preview.users.items.length, + posts: preview.posts.items.length, + }; + + const isAddress = isEncoded(searchTerm, Encoding.AccountAddress); + const hasExactUserMatch = isAddress + && preview.users.items.some( + (u) => u.address.toLowerCase() === searchTerm.toLowerCase(), + ); + const hasExactTokenMatch = isAddress + && preview.tokens.items.some( + (t) => (t as any).address?.toLowerCase() === searchTerm.toLowerCase(), + ); + + const score = (tab: SearchTab): number => { + const count = counts[tab]; + if (count === 0) return -1; + + let s = count; + if (tab === 'users' && hasExactUserMatch) s += 1000; + if (tab === 'tokens' && hasExactTokenMatch) s += 1000; + if (tab === activeTab) s += 0.5; + return s; + }; + + return [...SEARCH_TABS].sort((a, b) => score(b) - score(a)); + }, [activeTab, searchPreviewQuery.data, searchTerm]); + function updateOrderBy(val: OrderByOption) { setOrderBy(val); - setOrderDirection('DESC'); // Reset to default direction when using dropdown + setOrderDirection('DESC'); } function handleSort(sortKey: OrderByOption) { - if (orderBy === sortKey + if ( + orderBy === sortKey || (orderBy === 'newest' && sortKey === 'oldest') - || (orderBy === 'oldest' && sortKey === 'newest')) { - // Toggle direction if same column (or newest/oldest pair) + || (orderBy === 'oldest' && sortKey === 'newest') + ) { if (sortKey === 'newest' || sortKey === 'oldest') { - // For date-based sorting, toggle between newest and oldest setOrderBy(orderBy === 'newest' ? 'oldest' : 'newest'); - } else { - // For other columns, toggle the direction - setOrderDirection(orderDirection === 'DESC' ? 'ASC' : 'DESC'); + return; } - } else { - // Set new column with default DESC direction - setOrderBy(sortKey); - setOrderDirection('DESC'); + + setOrderDirection(orderDirection === 'DESC' ? 'ASC' : 'DESC'); + return; } + + setOrderBy(sortKey); + setOrderDirection('DESC'); } - // Intersection observer for infinite loading - useEffect(() => { - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.intersectionRatio === 1 && hasNextPage && !isFetching) { - fetchNextPage(); - } - }, - { threshold: 1 }, - ); + function toggleSection(tab: SearchTab) { + setExpandedSections((current) => ({ + ...current, + [tab]: !current[tab], + })); + } - if (loadMoreBtn.current) { - observer.observe(loadMoreBtn.current); + function openFullTopic(tab: SearchTab) { + setExpandedSections({ + tokens: false, + users: false, + posts: false, + }); + setActiveTab(tab); + setSearchInput(''); + setSearchTerm(''); + } + + function getSearchSectionState( + tab: SearchTab, + preview: SearchSection | undefined, + expanded: SearchSection | undefined, + fallback: SearchSection | undefined, + ) { + const hasResults = Boolean(preview?.items.length); + const isExpanded = expandedSections[tab]; + const usesExpandedData = isExpanded && Boolean(expanded?.items.length); + + if (hasResults) { + const items = usesExpandedData ? expanded!.items : preview!.items; + const totalItems = usesExpandedData ? expanded!.meta.totalItems : preview!.meta.totalItems; + + return { + items, + totalItems, + hasResults: true, + usesFallback: false, + canExpand: (preview?.meta.totalItems ?? 0) > SEARCH_PREVIEW_LIMIT, + }; } - return () => { - observer.disconnect(); + return { + items: fallback?.items ?? [], + totalItems: fallback?.meta.totalItems ?? 0, + hasResults: false, + usesFallback: true, + canExpand: false, }; - }, [hasNextPage, isFetching, fetchNextPage]); + } + + const tokenSearchState = getSearchSectionState( + 'tokens', + searchPreviewQuery.data?.tokens, + expandedTokenQuery.data as SearchSection | undefined, + fallbackTokensQuery.data, + ); + const userSearchState = getSearchSectionState( + 'users', + searchPreviewQuery.data?.users, + expandedUsersQuery.data as SearchSection | undefined, + fallbackUsersQuery.data, + ); + const postSearchState = getSearchSectionState( + 'posts', + searchPreviewQuery.data?.posts, + expandedPostsQuery.data as SearchSection | undefined, + fallbackPostsQuery.data, + ); + + const searchStates = { + tokens: tokenSearchState, + users: userSearchState, + posts: postSearchState, + }; + + const showSearchLoading = hasSearch && searchPreviewQuery.isLoading; + const searchError = hasSearch && searchPreviewQuery.isError + ? 'Unable to load search results right now. Please try again.' + : null; return ( -
+
- - - - {/* */} - - {/* Main content */}
- {/* Left: Token List */}
-
-
- Tokenized Trends +
+
+
+ + setSearchInput(event.target.value)} + placeholder="Search trends, users or posts" + className="h-12 rounded-2xl border-white/10 bg-white/[0.03] pl-11 pr-4 text-sm text-white placeholder:text-white/45 focus-visible:ring-[#1161FE]" + /> +
- {/* FILTERS */} -
- {/* OrderBy Filter */} -
- -
+ {!hasSearch ? ( +
+ {SEARCH_TABS.map((tab) => { + const isActive = activeTab === tab; - {/* Search */} - setSearch(e.target.value)} - placeholder="Search tokens" - className="px-2 py-2 h-10 min-h-[auto] bg-white/[0.02] text-white border border-white/10 backdrop-blur-[10px] rounded-lg text-xs focus:outline-none focus:border-[#1161FE] placeholder-white/50 transition-all duration-300 hover:bg-white/[0.05] w-full md:flex-1 min-w-[160px] md:max-w-none" - /> -
+ return ( + + ); + })} +
+ ) : null}
- {/* Message Box for no results */} - {(!data?.pages?.length || !data?.pages[0].items.length) && !isFetching && ( -
-

No Token Sales

-

No tokens found matching your criteria.

+ {searchError ? : null} + + {showSearchLoading ? : null} + + {hasSearch && !showSearchLoading && !searchError ? ( +
+ {searchOrder.map((tab) => { + const state = searchStates[tab]; + + if (!state.items.length) { + return null; + } + + const expanded = expandedSections[tab]; + const isLoadingExpanded = ( + (tab === 'tokens' && expandedTokenQuery.isLoading) + || (tab === 'users' && expandedUsersQuery.isLoading) + || (tab === 'posts' && expandedPostsQuery.isLoading) + ); + + const subtitle = state.hasResults + ? `${state.totalItems} result${state.totalItems === 1 ? '' : 's'}` + : getFallbackSubtitle(tab); + const footerLabel = expanded && !state.usesFallback ? 'Show less' : 'View all'; + + let sectionBody: React.ReactNode = null; + if (tab === 'tokens') { + sectionBody = ; + } else if (tab === 'users') { + sectionBody = ( + } + /> + ); + } else { + sectionBody = ( + + ); + } + + return ( + { + if (state.usesFallback) { + openFullTopic(tab); + return; + } + + toggleSection(tab); + }} + className="text-sm font-medium text-[#8bc9ff] hover:text-white transition-colors" + > + {footerLabel} + + ) : null} + > + {sectionBody} + {expanded && isLoadingExpanded ? : null} + + ); + })}
- )} - - {/* Token List Table */} - -
+ ) : null} - {/*
- -
*/} -
+ {!hasSearch && activeTab === 'tokens' ? ( + <> +
+ +
- {/* Load More Button — only for token list tab on mobile */} - {hasNextPage && ( -
- + + {(!tokenPages?.pages?.length || !tokenPages.pages[0].items.length) + && !isFetchingTokens ? ( + + ) : null} + + + + {hasNextPage ? ( +
+ +
+ ) : null} + + ) : null} + + {!hasSearch && activeTab === 'users' ? ( + + {usersTabQuery.isLoading ? : null} + {!usersTabQuery.isLoading && usersTabQuery.data?.items.length ? ( + + ) : null} + {!usersTabQuery.isLoading && !usersTabQuery.data?.items.length ? ( +
No leaderboard data is available right now.
+ ) : null} +
+ ) : null} + + {!hasSearch && activeTab === 'posts' ? ( + + {postsTabQuery.isLoading ? : null} + {!postsTabQuery.isLoading && postsTabQuery.data?.items.length ? ( + + ) : null} + {!postsTabQuery.isLoading && !postsTabQuery.data?.items.length ? ( +
No popular posts are available right now.
+ ) : null} +
+ ) : null}
- )} +
); }; diff --git a/src/features/trending/views/__tests__/TokenList.test.tsx b/src/features/trending/views/__tests__/TokenList.test.tsx new file mode 100644 index 000000000..546fd561e --- /dev/null +++ b/src/features/trending/views/__tests__/TokenList.test.tsx @@ -0,0 +1,288 @@ +/* eslint-disable object-curly-newline */ +import React from 'react'; +import { + fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { + beforeEach, describe, expect, it, vi, +} from 'vitest'; +import TokenList from '../TokenList'; + +const searchApiMocks = vi.hoisted(() => ({ + fetchTrendSearchPreview: vi.fn(), + fetchTrendSearchSection: vi.fn(), + fetchPopularPosts: vi.fn(), + fetchTopTraders: vi.fn(), + fetchTrendingTokens: vi.fn(), +})); + +const tokenServiceMocks = vi.hoisted(() => ({ + listAll: vi.fn(), +})); + +vi.mock('../../api/trendsSearch', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + fetchTrendSearchPreview: (...args: any[]) => searchApiMocks.fetchTrendSearchPreview(...args), + fetchTrendSearchSection: (...args: any[]) => searchApiMocks.fetchTrendSearchSection(...args), + fetchPopularPosts: (...args: any[]) => searchApiMocks.fetchPopularPosts(...args), + fetchTopTraders: (...args: any[]) => searchApiMocks.fetchTopTraders(...args), + fetchTrendingTokens: (...args: any[]) => searchApiMocks.fetchTrendingTokens(...args), + }; +}); + +vi.mock('../../../../api/generated', () => ({ + TokensService: { + listAll: (...args: any[]) => tokenServiceMocks.listAll(...args), + }, +})); + +vi.mock('../../../../seo/Head', () => ({ + Head: () => null, +})); + +vi.mock('../../../../components/Spinner', () => ({ + default: () => spinner, +})); + +vi.mock('../../../../components/Trendminer/LatestTransactionsCarousel', () => ({ + default: () =>
, +})); + +vi.mock('../../components/TokenListTable', () => ({ + default: ({ pages }: any) => ( +
+ {(pages?.[0]?.items ?? []).map((item: any) => ( + {item.name} + ))} +
+ ), +})); + +vi.mock('../../../social/components/ReplyToFeedItem', () => ({ + default: ({ item }: any) => ( +
{item.content}
+ ), +})); + +function renderView() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + + + + + , + ); +} + +describe('TokenList search experience', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + + tokenServiceMocks.listAll.mockResolvedValue({ + items: [{ address: 'ct_default', sale_address: 'ct_default_sale', name: 'DEFAULT' }], + meta: { currentPage: 1, totalPages: 1 }, + }); + + searchApiMocks.fetchTopTraders.mockResolvedValue({ + items: [{ address: 'ak_trader', chain_name: 'alpha.chain', pnl_usd: 1200 }], + meta: { totalItems: 1, totalPages: 1, currentPage: 1 }, + }); + + searchApiMocks.fetchTrendingTokens.mockResolvedValue({ + items: [{ address: 'ct_trending', sale_address: 'ct_trending_sale', name: 'TRENDING' }], + meta: { totalItems: 1, totalPages: 1, currentPage: 1 }, + }); + + searchApiMocks.fetchPopularPosts.mockResolvedValue({ + items: [{ + id: 'post_1_v3', + sender_address: 'ak_author', + content: 'Popular post', + media: [], + topics: [], + total_comments: 2, + tx_hash: '', + tx_args: [], + contract_address: '', + type: 'post', + created_at: '2026-03-27T12:00:00.000Z', + }], + meta: { totalItems: 1, totalPages: 1, currentPage: 1 }, + }); + + searchApiMocks.fetchTrendSearchPreview.mockResolvedValue({ + tokens: { + items: [ + { address: 'ct_1', sale_address: 'ct_sale_1', name: 'ALPHA', symbol: 'ALPHA', price: '1', holders_count: 5, market_cap: '10' }, + { address: 'ct_2', sale_address: 'ct_sale_2', name: 'BETA', symbol: 'BETA', price: '2', holders_count: 6, market_cap: '11' }, + { address: 'ct_3', sale_address: 'ct_sale_3', name: 'GAMMA', symbol: 'GAMMA', price: '3', holders_count: 7, market_cap: '12' }, + ], + meta: { totalItems: 4, totalPages: 2, currentPage: 1 }, + }, + users: { + items: [{ address: 'ak_user', chain_name: 'user.chain', total_volume: '10', total_tx_count: 3, total_created_tokens: 1 }], + meta: { totalItems: 1, totalPages: 1, currentPage: 1 }, + }, + posts: { + items: [{ + id: 'post_search_v3', + sender_address: 'ak_post', + content: 'Searchable post', + media: [], + topics: [], + total_comments: 1, + tx_hash: '', + tx_args: [], + contract_address: '', + type: 'post', + created_at: '2026-03-27T12:00:00.000Z', + }], + meta: { totalItems: 1, totalPages: 1, currentPage: 1 }, + }, + }); + + searchApiMocks.fetchTrendSearchSection.mockResolvedValue({ + items: [ + { address: 'ct_1', sale_address: 'ct_sale_1', name: 'ALPHA', symbol: 'ALPHA', price: '1', holders_count: 5, market_cap: '10' }, + { address: 'ct_2', sale_address: 'ct_sale_2', name: 'BETA', symbol: 'BETA', price: '2', holders_count: 6, market_cap: '11' }, + { address: 'ct_3', sale_address: 'ct_sale_3', name: 'GAMMA', symbol: 'GAMMA', price: '3', holders_count: 7, market_cap: '12' }, + { address: 'ct_4', sale_address: 'ct_sale_4', name: 'DELTA', symbol: 'DELTA', price: '4', holders_count: 8, market_cap: '13' }, + ], + meta: { totalItems: 4, totalPages: 1, currentPage: 1 }, + }); + + Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + value: function IntersectionObserverMock() { + return { + observe() {}, + disconnect() {}, + }; + }, + }); + }); + + it('shows default tab content and switches to users and posts tabs', async () => { + renderView(); + + await waitFor(() => { + expect(screen.getByTestId('token-list-table')).toBeInTheDocument(); + }); + expect(screen.getByText('Tokenized Trends')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Tokenize Trend' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Users' })); + await waitFor(() => { + expect(screen.getByText('alpha.chain')).toBeInTheDocument(); + }); + expect(screen.getByText('Top Traders')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Posts' })); + await waitFor(() => { + expect(screen.getByText('Popular post')).toBeInTheDocument(); + }); + expect(screen.getByText('Popular Posts')).toBeInTheDocument(); + }); + + it('renders search sections and expands tokens with view all', async () => { + renderView(); + + fireEvent.change(screen.getByLabelText('Search tokens, users and posts'), { + target: { value: 'hello' }, + }); + + await waitFor(() => { + expect(searchApiMocks.fetchTrendSearchPreview).toHaveBeenCalledWith('hello'); + }, { timeout: 2000 }); + + expect(screen.queryByRole('button', { name: 'Users' })).not.toBeInTheDocument(); + expect(screen.getByText('ALPHA')).toBeInTheDocument(); + expect(screen.getByText('user.chain')).toBeInTheDocument(); + expect(screen.getByText('Searchable post')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'View all' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'View all' })); + + await waitFor(() => { + expect(searchApiMocks.fetchTrendSearchSection).toHaveBeenCalledWith('tokens', 'hello'); + }); + + await waitFor(() => { + expect(screen.getByText('DELTA')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: 'Show less' })).toBeInTheDocument(); + }); + + it('shows fallback view all buttons and opens the full topic when nothing is found', async () => { + searchApiMocks.fetchTrendSearchPreview.mockResolvedValueOnce({ + tokens: { + items: [], + meta: { totalItems: 0, totalPages: 0, currentPage: 1 }, + }, + users: { + items: [], + meta: { totalItems: 0, totalPages: 0, currentPage: 1 }, + }, + posts: { + items: [], + meta: { totalItems: 0, totalPages: 0, currentPage: 1 }, + }, + }); + + renderView(); + + fireEvent.change(screen.getByLabelText('Search tokens, users and posts'), { + target: { value: 'missing' }, + }); + + await waitFor(() => { + expect(searchApiMocks.fetchTrendSearchPreview).toHaveBeenCalledWith('missing'); + }, { timeout: 2000 }); + + await waitFor(() => { + expect(searchApiMocks.fetchTrendingTokens).toHaveBeenCalled(); + expect(searchApiMocks.fetchTopTraders).toHaveBeenCalled(); + expect(searchApiMocks.fetchPopularPosts).toHaveBeenCalled(); + }); + + const viewAllButtons = await screen.findAllByRole('button', { name: 'View all' }); + expect(viewAllButtons).toHaveLength(3); + + fireEvent.click(viewAllButtons[1]); + + await waitFor(() => { + expect(screen.getByText('Top Traders')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: 'Users' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Tokens' })).toBeInTheDocument(); + }); + + it('shows error panel when search preview fails', async () => { + searchApiMocks.fetchTrendSearchPreview.mockRejectedValue( + new Error('Network error'), + ); + + renderView(); + + fireEvent.change(screen.getByLabelText('Search tokens, users and posts'), { + target: { value: 'fail' }, + }); + + await waitFor(() => { + expect(screen.getByText('Unable to load search results right now. Please try again.')).toBeInTheDocument(); + }, { timeout: 5000 }); + }); +});