From 1e23cf53d04983ba8a76d36a55baa2ee89591d9e Mon Sep 17 00:00:00 2001 From: Bathoul Mohammed Date: Wed, 30 Sep 2026 08:50:04 +0100 Subject: [PATCH 1/2] feat(search): build global search across keys, creators, and transactions (#1053) --- src/components/common/GlobalSearch.tsx | 146 ++++---- .../common/__tests__/GlobalSearch.test.tsx | 124 ++++++- src/pages/SearchResultsPage.tsx | 320 ++++++++++++++++++ src/routes.tsx | 6 + src/services/__tests__/search.service.test.ts | 10 +- src/services/search.service.ts | 30 +- 6 files changed, 529 insertions(+), 107 deletions(-) create mode 100644 src/pages/SearchResultsPage.tsx diff --git a/src/components/common/GlobalSearch.tsx b/src/components/common/GlobalSearch.tsx index 7c864b45..5b310499 100644 --- a/src/components/common/GlobalSearch.tsx +++ b/src/components/common/GlobalSearch.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Search, Loader2, Key, User, FileText, X } from 'lucide-react'; +import { Search, Loader2, Key, User, Hash, X, ArrowRight } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useDebounce } from '@/hooks/useDebounce'; import { @@ -7,7 +7,7 @@ import { type GlobalSearchResults, type SearchKeyItem, type SearchCreatorItem, - type SearchProposalItem, + type SearchTransactionItem, } from '@/services/search.service'; import { highlightMatchingSubstring } from '@/utils/substringHighlight.utils'; import { cn } from '@/lib/utils'; @@ -19,23 +19,15 @@ interface GlobalSearchProps { placeholder?: string; } -const proposalStatusClasses: Record = { - active: 'border-amber-500/30 bg-amber-500/10 text-amber-400', - passed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', - rejected: 'border-red-500/30 bg-red-500/10 text-red-400', - executed: 'border-blue-500/30 bg-blue-500/10 text-blue-400', - cancelled: 'border-white/10 bg-white/[0.04] text-white/40', -}; - const GlobalSearch: React.FC = ({ className, - placeholder = 'Search keys, creators, proposals...', + placeholder = 'Search keys, creators, transactions...', }) => { const [query, setQuery] = useState(''); const [results, setResults] = useState({ keys: [], creators: [], - proposals: [], + transactions: [], }); const [isLoading, setIsLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); @@ -66,7 +58,7 @@ const GlobalSearch: React.FC = ({ useEffect(() => { const trimmed = debouncedQuery.trim(); if (!trimmed) { - setResults({ keys: [], creators: [], proposals: [] }); + setResults({ keys: [], creators: [], transactions: [] }); setIsLoading(false); setIsOpen(false); return; @@ -83,14 +75,14 @@ const GlobalSearch: React.FC = ({ setResults({ keys: Array.isArray(data?.keys) ? data.keys : [], creators: Array.isArray(data?.creators) ? data.creators : [], - proposals: Array.isArray(data?.proposals) ? data.proposals : [], + transactions: Array.isArray(data?.transactions) ? data.transactions : [], }); setIsLoading(false); } }) .catch(() => { if (!cancelled) { - setResults({ keys: [], creators: [], proposals: [] }); + setResults({ keys: [], creators: [], transactions: [] }); setIsLoading(false); } }); @@ -117,7 +109,7 @@ const GlobalSearch: React.FC = ({ const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setQuery(''); - setResults({ keys: [], creators: [], proposals: [] }); + setResults({ keys: [], creators: [], transactions: [] }); setIsOpen(false); inputRef.current?.blur(); } @@ -125,7 +117,7 @@ const GlobalSearch: React.FC = ({ const handleNavigate = (path: string) => { setQuery(''); - setResults({ keys: [], creators: [], proposals: [] }); + setResults({ keys: [], creators: [], transactions: [] }); setIsOpen(false); navigate(path); }; @@ -139,19 +131,19 @@ const GlobalSearch: React.FC = ({ handleNavigate(`/creator/${creator.id}`); }; - const handleSelectProposal = (proposal: SearchProposalItem) => { - handleNavigate(`/governance?proposal=${proposal.id}`); + const handleSelectTransaction = (transaction: SearchTransactionItem) => { + handleNavigate(`/search?q=${query}&type=transactions`); }; const handleClear = () => { setQuery(''); - setResults({ keys: [], creators: [], proposals: [] }); + setResults({ keys: [], creators: [], transactions: [] }); setIsOpen(false); inputRef.current?.focus(); }; const totalResultsCount = - results.keys.length + results.creators.length + results.proposals.length; + results.keys.length + results.creators.length + results.transactions.length; const hasQuery = query.trim() !== ''; return ( @@ -174,7 +166,7 @@ const GlobalSearch: React.FC = ({ placeholder={placeholder} className="w-full rounded-xl border border-white/10 bg-white/5 py-1.5 pl-9 pr-14 text-xs text-white placeholder:text-white/40 focus:border-amber-500/50 focus:bg-white/10 focus:outline-none focus:ring-1 focus:ring-amber-500/20" data-testid="global-search-input" - aria-label="Global search keys, creators, and governance proposals" + aria-label="Global search keys, creators, and transactions" role="combobox" aria-expanded={isOpen} aria-haspopup="listbox" @@ -264,7 +256,7 @@ const GlobalSearch: React.FC = ({
    - {results.keys.map(key => { + {results.keys.slice(0, 3).map(key => { const priceDisplay = formatDisplayKeyPrice( resolveCreatorKeyPriceStroops(key) ); @@ -320,6 +312,17 @@ const GlobalSearch: React.FC = ({ ); })}
+ {results.keys.length > 3 && ( + + )} )} @@ -334,7 +337,7 @@ const GlobalSearch: React.FC = ({
    - {results.creators.map(creator => { + {results.creators.slice(0, 3).map(creator => { const displayName = creator.name || creator.title || 'Creator'; const imageUri = creator.avatarUri || creator.thumbnail; return ( @@ -379,60 +382,67 @@ const GlobalSearch: React.FC = ({ ); })}
+ {results.creators.length > 3 && ( + + )} )} - {/* Proposals group */} - {results.proposals.length > 0 && ( -
+ {/* Transactions group */} + {results.transactions.length > 0 && ( +
-
    - {results.proposals.map(proposal => { - const statusStyle = - proposalStatusClasses[proposal.status] || - 'border-white/10 bg-white/5 text-white/50'; - return ( -
  • - -
  • - ); - })} + )} +
+ + + ))} + {results.transactions.length > 3 && ( + + )}
)} @@ -443,7 +453,7 @@ const GlobalSearch: React.FC = ({ >

No results found

- No keys, creators, or proposals matching “{debouncedQuery}” + No keys, creators, or transactions matching “{debouncedQuery}”

)} diff --git a/src/components/common/__tests__/GlobalSearch.test.tsx b/src/components/common/__tests__/GlobalSearch.test.tsx index c2a14bbc..a2d35e81 100644 --- a/src/components/common/__tests__/GlobalSearch.test.tsx +++ b/src/components/common/__tests__/GlobalSearch.test.tsx @@ -20,7 +20,7 @@ vi.mock('@/services/search.service', () => ({ }, })); -describe('GlobalSearch (#933)', () => { +describe('GlobalSearch (#1053)', () => { const mockResults: GlobalSearchResults = { keys: [ { @@ -40,12 +40,11 @@ describe('GlobalSearch (#933)', () => { isVerified: true, }, ], - proposals: [ + transactions: [ { - id: 'proposal-1', - title: 'Proposal: Increase Quorum', - description: 'A proposal to improve governance participation.', - status: 'active', + id: 'tx-1', + hash: 'abc123def456', + type: 'Buy', }, ], }; @@ -144,7 +143,7 @@ describe('GlobalSearch (#933)', () => { expect(screen.getByTestId('global-search-group-keys')).toBeInTheDocument(); }); - it('results grouped correctly by type in dropdown (Keys, Creators, Proposals)', async () => { + it('results grouped correctly by type in dropdown (Keys, Creators, Transactions)', async () => { vi.mocked(searchService.search).mockResolvedValue(mockResults); render( @@ -173,10 +172,9 @@ describe('GlobalSearch (#933)', () => { expect(screen.getByText('Alice Creator')).toBeInTheDocument(); expect(screen.getByText('@alice_creator')).toBeInTheDocument(); - // Verify Proposals group - expect(screen.getByTestId('global-search-group-proposals')).toBeInTheDocument(); - expect(screen.getByText('Proposal: Increase Quorum')).toBeInTheDocument(); - expect(screen.getByText('active')).toBeInTheDocument(); + // Verify Transactions group + expect(screen.getByTestId('global-search-group-transactions')).toBeInTheDocument(); + expect(screen.getByText('abc123def456')).toBeInTheDocument(); }); it('clicking a key result navigates to the correct detail page', async () => { @@ -233,7 +231,7 @@ describe('GlobalSearch (#933)', () => { expect(input).toHaveValue(''); }); - it('clicking a proposal result navigates to the governance proposal', async () => { + it('clicking a transaction result navigates to search results page', async () => { vi.mocked(searchService.search).mockResolvedValue(mockResults); render( @@ -243,19 +241,19 @@ describe('GlobalSearch (#933)', () => { ); const input = screen.getByTestId('global-search-input'); - fireEvent.change(input, { target: { value: 'Proposal' } }); + fireEvent.change(input, { target: { value: 'abc' } }); await act(async () => { vi.advanceTimersByTime(300); }); await waitFor(() => { - expect(screen.getByTestId('global-search-item-proposal')).toBeInTheDocument(); + expect(screen.getByTestId('global-search-item-transaction')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('global-search-item-proposal')); + fireEvent.click(screen.getByTestId('global-search-item-transaction')); - expect(mockNavigate).toHaveBeenCalledWith('/governance?proposal=proposal-1'); + expect(mockNavigate).toHaveBeenCalledWith('/search?q=abc&type=transactions'); expect(screen.queryByTestId('global-search-dropdown')).not.toBeInTheDocument(); expect(input).toHaveValue(''); }); @@ -264,7 +262,7 @@ describe('GlobalSearch (#933)', () => { vi.mocked(searchService.search).mockResolvedValue({ keys: [], creators: [], - proposals: [], + transactions: [], }); render( @@ -286,7 +284,7 @@ describe('GlobalSearch (#933)', () => { expect(screen.getByText('No results found')).toBeInTheDocument(); expect( - screen.getByText(/No keys, creators, or proposals matching/i) + screen.getByText(/No keys, creators, or transactions matching/i) ).toBeInTheDocument(); }); @@ -343,4 +341,94 @@ describe('GlobalSearch (#933)', () => { expect(screen.queryByTestId('global-search-dropdown')).not.toBeInTheDocument(); }); + + it('shows only top 3 results per group and "View all" link when more than 3 results', async () => { + const manyResults: GlobalSearchResults = { + keys: [ + { id: 'key-1', title: 'Key 1', priceStroops: 10000000, creatorId: 'creator-1' }, + { id: 'key-2', title: 'Key 2', priceStroops: 20000000, creatorId: 'creator-2' }, + { id: 'key-3', title: 'Key 3', priceStroops: 30000000, creatorId: 'creator-3' }, + { id: 'key-4', title: 'Key 4', priceStroops: 40000000, creatorId: 'creator-4' }, + ], + creators: [ + { id: 'creator-1', name: 'Creator 1' }, + { id: 'creator-2', name: 'Creator 2' }, + { id: 'creator-3', name: 'Creator 3' }, + { id: 'creator-4', name: 'Creator 4' }, + ], + transactions: [ + { id: 'tx-1', hash: 'hash1' }, + { id: 'tx-2', hash: 'hash2' }, + { id: 'tx-3', hash: 'hash3' }, + { id: 'tx-4', hash: 'hash4' }, + ], + }; + + vi.mocked(searchService.search).mockResolvedValue(manyResults); + + render( + + + + ); + + const input = screen.getByTestId('global-search-input'); + fireEvent.change(input, { target: { value: 'test' } }); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.getByTestId('global-search-dropdown')).toBeInTheDocument(); + }); + + // Should show "View all" link for keys + expect(screen.getByTestId('global-search-view-all-keys')).toBeInTheDocument(); + expect(screen.getByText('View all 4 keys')).toBeInTheDocument(); + + // Should show "View all" link for creators + expect(screen.getByTestId('global-search-view-all-creators')).toBeInTheDocument(); + expect(screen.getByText('View all 4 creators')).toBeInTheDocument(); + + // Should show "View all" link for transactions + expect(screen.getByTestId('global-search-view-all-transactions')).toBeInTheDocument(); + expect(screen.getByText('View all 4 transactions')).toBeInTheDocument(); + }); + + it('"View all" link navigates to search results page with correct filter', async () => { + const manyResults: GlobalSearchResults = { + keys: [ + { id: 'key-1', title: 'Key 1', priceStroops: 10000000, creatorId: 'creator-1' }, + { id: 'key-2', title: 'Key 2', priceStroops: 20000000, creatorId: 'creator-2' }, + { id: 'key-3', title: 'Key 3', priceStroops: 30000000, creatorId: 'creator-3' }, + { id: 'key-4', title: 'Key 4', priceStroops: 40000000, creatorId: 'creator-4' }, + ], + creators: [], + transactions: [], + }; + + vi.mocked(searchService.search).mockResolvedValue(manyResults); + + render( + + + + ); + + const input = screen.getByTestId('global-search-input'); + fireEvent.change(input, { target: { value: 'test' } }); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.getByTestId('global-search-view-all-keys')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('global-search-view-all-keys')); + + expect(mockNavigate).toHaveBeenCalledWith('/search?q=test&type=keys'); + }); }); diff --git a/src/pages/SearchResultsPage.tsx b/src/pages/SearchResultsPage.tsx new file mode 100644 index 00000000..000acd7e --- /dev/null +++ b/src/pages/SearchResultsPage.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState } from 'react'; +import { Search, Key, User, Hash, ArrowLeft } from 'lucide-react'; +import { Link, useSearchParams } from 'react-router'; +import { useDebounce } from '@/hooks/useDebounce'; +import { + searchService, + type GlobalSearchResults, + type SearchKeyItem, + type SearchCreatorItem, + type SearchTransactionItem, +} from '@/services/search.service'; +import { highlightMatchingSubstring } from '@/utils/substringHighlight.utils'; +import { cn } from '@/lib/utils'; +import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; + +export default function SearchResultsPage() { + const [searchParams] = useSearchParams(); + const query = searchParams.get('q') || ''; + const typeFilter = searchParams.get('type') || 'all'; + + const [results, setResults] = useState({ + keys: [], + creators: [], + transactions: [], + }); + const [isLoading, setIsLoading] = useState(false); + + const debouncedQuery = useDebounce(query, 300); + + useDocumentTitle(`Search: ${query} — AccessLayer`); + + useEffect(() => { + const trimmed = debouncedQuery.trim(); + if (!trimmed) { + setResults({ keys: [], creators: [], transactions: [] }); + setIsLoading(false); + return; + } + + let cancelled = false; + setIsLoading(true); + + searchService + .search(trimmed) + .then(data => { + if (!cancelled) { + setResults({ + keys: Array.isArray(data?.keys) ? data.keys : [], + creators: Array.isArray(data?.creators) ? data.creators : [], + transactions: Array.isArray(data?.transactions) ? data.transactions : [], + }); + setIsLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setResults({ keys: [], creators: [], transactions: [] }); + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [debouncedQuery]); + + const totalResultsCount = + results.keys.length + results.creators.length + results.transactions.length; + + const filteredResults = { + keys: typeFilter === 'all' || typeFilter === 'keys' ? results.keys : [], + creators: typeFilter === 'all' || typeFilter === 'creators' ? results.creators : [], + transactions: typeFilter === 'all' || typeFilter === 'transactions' ? results.transactions : [], + }; + + return ( +
+ +
+ ); +} diff --git a/src/routes.tsx b/src/routes.tsx index 8d9300c1..ce7158ab 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -26,6 +26,7 @@ import StatusPage from './pages/StatusPage'; import HolderLeaderboardPage from './pages/HolderLeaderboardPage'; import BundlesPage from './pages/BundlesPage'; import BundleDetailPage from './pages/BundleDetailPage'; +import SearchResultsPage from './pages/SearchResultsPage'; export const routes = [ { @@ -177,6 +178,11 @@ export const routes = [ path: '/status', element: , }, + { + // Global search results page (#1053). + path: '/search', + element: , + }, { path: '*', element: , diff --git a/src/services/__tests__/search.service.test.ts b/src/services/__tests__/search.service.test.ts index 64f85a52..ac42a123 100644 --- a/src/services/__tests__/search.service.test.ts +++ b/src/services/__tests__/search.service.test.ts @@ -29,7 +29,7 @@ describe('searchService', () => { it('returns empty results when query is whitespace without calling API', async () => { const result = await searchService.search(' '); - expect(result).toEqual({ keys: [], creators: [], proposals: [] }); + expect(result).toEqual({ keys: [], creators: [], transactions: [] }); expect(mockGet).not.toHaveBeenCalled(); }); @@ -41,8 +41,8 @@ describe('searchService', () => { creators: [ { id: 'c1', name: 'Creator One', socialHandle: 'c_one' }, ], - proposals: [ - { id: 'p1', title: 'Proposal One', status: 'active' as const }, + transactions: [ + { id: 't1', hash: 'abc123', type: 'Buy' }, ], }; @@ -54,7 +54,7 @@ describe('searchService', () => { expect(mockGet).toHaveBeenCalledWith('/search', { params: { q: 'One' } }); expect(result.keys).toHaveLength(1); expect(result.creators).toHaveLength(1); - expect(result.proposals).toHaveLength(1); + expect(result.transactions).toHaveLength(1); }); it('handles results wrapped in nested results object', async () => { @@ -62,7 +62,7 @@ describe('searchService', () => { results: { keys: [{ id: 'k1', title: 'Key One' }], creators: [], - proposals: [], + transactions: [], }, }; diff --git a/src/services/search.service.ts b/src/services/search.service.ts index 8d507c41..17d9bc29 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -1,7 +1,6 @@ // src/services/search.service.ts import { BaseApiService, type APIResponse } from './api.service'; import { cacheManager } from '@/utils/cache.utils'; -import type { ProposalStatus } from '@/types/governance'; export interface SearchKeyItem { id: string; @@ -28,34 +27,33 @@ export interface SearchCreatorItem { category?: string; } -export interface SearchProposalItem { +export interface SearchTransactionItem { id: string; - title: string; - description?: string; - status: ProposalStatus; - creatorId?: string; - quorumBps?: number; - startDate?: string; - endDate?: string; + hash: string; + type?: string; + timestamp?: string; + from?: string; + to?: string; + amount?: string; } export interface GlobalSearchResults { keys: SearchKeyItem[]; creators: SearchCreatorItem[]; - proposals: SearchProposalItem[]; + transactions: SearchTransactionItem[]; } const SEARCH_CACHE_TTL = 15_000; // 15 seconds class SearchService extends BaseApiService { /** - * Search across keys, creators, and governance proposals. + * Search across keys, creators, and transactions. * GET /search?q=:query */ async search(query: string): Promise { const trimmed = query.trim(); if (!trimmed) { - return { keys: [], creators: [], proposals: [] }; + return { keys: [], creators: [], transactions: [] }; } const cacheKey = `global_search_${trimmed.toLowerCase()}`; @@ -70,20 +68,20 @@ class SearchService extends BaseApiService { }); const raw = response.data.data; - let result: GlobalSearchResults = { keys: [], creators: [], proposals: [] }; + let result: GlobalSearchResults = { keys: [], creators: [], transactions: [] }; if (raw && typeof raw === 'object') { if ('results' in raw && raw.results && typeof raw.results === 'object') { result = { keys: Array.isArray(raw.results.keys) ? raw.results.keys : [], creators: Array.isArray(raw.results.creators) ? raw.results.creators : [], - proposals: Array.isArray(raw.results.proposals) ? raw.results.proposals : [], + transactions: Array.isArray(raw.results.transactions) ? raw.results.transactions : [], }; - } else if ('keys' in raw || 'creators' in raw || 'proposals' in raw) { + } else if ('keys' in raw || 'creators' in raw || 'transactions' in raw) { result = { keys: Array.isArray((raw as GlobalSearchResults).keys) ? (raw as GlobalSearchResults).keys : [], creators: Array.isArray((raw as GlobalSearchResults).creators) ? (raw as GlobalSearchResults).creators : [], - proposals: Array.isArray((raw as GlobalSearchResults).proposals) ? (raw as GlobalSearchResults).proposals : [], + transactions: Array.isArray((raw as GlobalSearchResults).transactions) ? (raw as GlobalSearchResults).transactions : [], }; } } From a81333a5b00e208c8bbc1393cccab2c599e44a67 Mon Sep 17 00:00:00 2001 From: Bathoul Mohammed Date: Fri, 2 Oct 2026 10:31:46 +0100 Subject: [PATCH 2/2] Fix linting errors: remove unused type imports and parameters - Remove unused SearchTransactionItem import from GlobalSearch.tsx - Remove unused transaction parameter from handleSelectTransaction function - Remove unused SearchKeyItem, SearchCreatorItem, and SearchTransactionItem imports from SearchResultsPage.tsx Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/components/common/GlobalSearch.tsx | 5 ++--- src/pages/SearchResultsPage.tsx | 3 --- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/common/GlobalSearch.tsx b/src/components/common/GlobalSearch.tsx index 5b310499..d4c46f27 100644 --- a/src/components/common/GlobalSearch.tsx +++ b/src/components/common/GlobalSearch.tsx @@ -7,7 +7,6 @@ import { type GlobalSearchResults, type SearchKeyItem, type SearchCreatorItem, - type SearchTransactionItem, } from '@/services/search.service'; import { highlightMatchingSubstring } from '@/utils/substringHighlight.utils'; import { cn } from '@/lib/utils'; @@ -131,7 +130,7 @@ const GlobalSearch: React.FC = ({ handleNavigate(`/creator/${creator.id}`); }; - const handleSelectTransaction = (transaction: SearchTransactionItem) => { + const handleSelectTransaction = () => { handleNavigate(`/search?q=${query}&type=transactions`); }; @@ -411,7 +410,7 @@ const GlobalSearch: React.FC = ({