From b4468d0fb6277ce9ac5d380b37892f78d8062043 Mon Sep 17 00:00:00 2001 From: thebabalola Date: Mon, 3 Aug 2026 14:39:44 +0100 Subject: [PATCH 1/2] feat: add search history tracking with recency-based suggestions (#124) - Add hooks/useSearchHistory.ts hook for managing search history in localStorage - Max 20 unique searches stored, deduplicated, sorted by recency - Integrate useSearchHistory into SearchOverlay component - Show recent searches as dropdown when search input is empty - Allow clicking on a suggestion to populate and submit the search - Add clear history button with Trash2 icon - Each search query saved with timestamp --- components/common/SearchOverlay.tsx | 89 ++++++++++++++++++++++------- hooks/useSearchHistory.ts | 43 ++++++++++++++ 2 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 hooks/useSearchHistory.ts diff --git a/components/common/SearchOverlay.tsx b/components/common/SearchOverlay.tsx index 851f431..d578b83 100644 --- a/components/common/SearchOverlay.tsx +++ b/components/common/SearchOverlay.tsx @@ -1,7 +1,8 @@ 'use client'; -import React, { useState, useEffect, useRef, KeyboardEvent } from 'react'; -import { Search, X } from 'lucide-react'; +import React, { useState, useEffect, useRef, useCallback, KeyboardEvent } from 'react'; +import { Search, X, Clock, Trash2 } from 'lucide-react'; +import { useSearchHistory } from '@/hooks/useSearchHistory'; interface SearchResult { id: string; @@ -24,6 +25,7 @@ export default function SearchOverlay() { const inputRef = useRef(null); const overlayRef = useRef(null); const previousFocusRef = useRef(null); + const { suggestions, addSearch, clearHistory } = useSearchHistory(); useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { @@ -54,6 +56,15 @@ export default function SearchOverlay() { } }, [isOpen]); + const handleSearchSubmit = useCallback( + (searchQuery: string) => { + if (!searchQuery.trim()) return; + addSearch(searchQuery); + setIsOpen(false); + }, + [addSearch] + ); + const filteredResults = MOCK_RESULTS.filter( (res) => res.title.toLowerCase().includes(query.toLowerCase()) || @@ -68,35 +79,38 @@ export default function SearchOverlay() { if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedIndex((prev) => - prev < filteredResults.length - 1 ? prev + 1 : prev - ); + setSelectedIndex((prev) => (prev < filteredResults.length - 1 ? prev + 1 : prev)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); } else if (e.key === 'Enter' && selectedIndex >= 0) { e.preventDefault(); - // Trigger selection const selected = filteredResults[selectedIndex]; if (selected) { - // console.log('Selected:', selected); - setIsOpen(false); + handleSearchSubmit(selected.title); } } else if (e.key === 'Tab') { - // Focus trap containment e.preventDefault(); inputRef.current?.focus(); } }; + const handleSuggestionClick = useCallback( + (suggestionQuery: string) => { + setQuery(suggestionQuery); + handleSearchSubmit(suggestionQuery); + }, + [handleSearchSubmit] + ); + if (!isOpen) return null; return (
setIsOpen(false)} >
{ setQuery(e.target.value); setSelectedIndex(-1); }} + onKeyDown={(e) => { + if (e.key === 'Enter' && query.trim()) { + handleSearchSubmit(query); + } + }} /> @@ -135,15 +154,13 @@ export default function SearchOverlay() { filteredResults.map((result, index) => (
{ - setIsOpen(false); + handleSearchSubmit(result.title); }} onMouseEnter={() => setSelectedIndex(index)} > @@ -153,11 +170,43 @@ export default function SearchOverlay() { )) ) : (
- No results found for "{query}" + No results found for "{query}"
)}
)} + + {!query && suggestions.length > 0 && ( +
+
+ + Recent Searches + + +
+ {suggestions.map((entry, index) => ( +
handleSuggestionClick(entry.query)} + onMouseEnter={() => setSelectedIndex(index)} + > + + {entry.query} +
+ ))} +
+ )} +
diff --git a/hooks/useSearchHistory.ts b/hooks/useSearchHistory.ts new file mode 100644 index 0000000..d06e1ae --- /dev/null +++ b/hooks/useSearchHistory.ts @@ -0,0 +1,43 @@ +'use client'; + +import { useCallback } from 'react'; +import { useLocalStorage } from './useLocalStorage'; + +interface SearchHistoryEntry { + query: string; + timestamp: number; +} + +const MAX_HISTORY = 20; +const STORAGE_KEY = 'search-history'; + +export function useSearchHistory() { + const [history, setHistory] = useLocalStorage(STORAGE_KEY, []); + + const addSearch = useCallback( + (query: string) => { + if (!query.trim()) return; + + const trimmed = query.trim(); + const filtered = history.filter((entry) => entry.query !== trimmed); + const newEntry: SearchHistoryEntry = { + query: trimmed, + timestamp: Date.now(), + }; + setHistory([newEntry, ...filtered].slice(0, MAX_HISTORY)); + }, + [history, setHistory] + ); + + const clearHistory = useCallback(() => { + setHistory([]); + }, [setHistory]); + + const suggestions = history; + + return { + suggestions, + addSearch, + clearHistory, + }; +} From 9aa8807cc04d6f0c78a1fd9e27439cb291ab27f2 Mon Sep 17 00:00:00 2001 From: thebabalola Date: Mon, 3 Aug 2026 14:46:13 +0100 Subject: [PATCH 2/2] feat: add TransactionContext for optimistic wallet transaction tracking (#123) - Create context/TransactionContext.tsx with transaction state management - Track transactions with { id, hash, type, status, timestamp } - Support addTransaction, updateTransactionStatus, clearTransaction, clearAllTransactions - Auto-poll transaction status every 2 seconds - Show toast notifications for pending, confirmed, and failed transactions - Auto-clear confirmed transactions after 60 seconds - Transaction history limited to last 20 entries - Add TransactionProvider to the provider chain in provider.tsx --- context/TransactionContext.tsx | 191 +++++++++++++++++++++++++++++++++ context/provider.tsx | 15 +-- 2 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 context/TransactionContext.tsx diff --git a/context/TransactionContext.tsx b/context/TransactionContext.tsx new file mode 100644 index 0000000..c28e63c --- /dev/null +++ b/context/TransactionContext.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, + type ReactNode, +} from 'react'; +import { useToast } from './ToastContext'; + +export type TransactionType = 'buy' | 'stake' | 'vote'; +export type TransactionStatus = 'pending' | 'confirmed' | 'failed'; + +export interface Transaction { + id: string; + hash: string; + type: TransactionType; + status: TransactionStatus; + timestamp: number; +} + +interface TransactionContextValue { + transactions: Transaction[]; + addTransaction: (hash: string, type: TransactionType) => string; + updateTransactionStatus: (id: string, status: TransactionStatus) => void; + clearTransaction: (id: string) => void; + clearAllTransactions: () => void; +} + +const TransactionContext = createContext(null); + +const MAX_TRANSACTIONS = 20; +const POLL_INTERVAL_MS = 2000; +const AUTO_CLEAR_MS = 60_000; + +function generateId(): string { + return `tx-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export function TransactionProvider({ children }: { children: ReactNode }) { + const { toast } = useToast(); + const [transactions, setTransactions] = useState([]); + const pollingRef = useRef>>(new Map()); + + const pollTransaction = useCallback( + (id: string, hash: string) => { + const interval = setInterval(async () => { + try { + const response = await fetch( + `/api/transactions/${hash}/status`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + } + ); + + if (!response.ok) return; + + const data = (await response.json()) as { + status?: string; + confirmed?: boolean; + }; + + const status: TransactionStatus = + data.confirmed === true + ? 'confirmed' + : data.status === 'failed' + ? 'failed' + : 'pending'; + + setTransactions((prev) => + prev.map((tx) => (tx.id === id ? { ...tx, status } : tx)) + ); + + if (status === 'confirmed') { + toast({ + type: 'success', + title: 'Transaction confirmed', + message: `Transaction ${hash.slice(0, 10)}... confirmed`, + }); + clearInterval(interval); + pollingRef.current.delete(id); + + setTimeout(() => { + setTransactions((prev) => prev.filter((tx) => tx.id !== id)); + }, AUTO_CLEAR_MS); + } else if (status === 'failed') { + toast({ + type: 'error', + title: 'Transaction failed', + message: `Transaction ${hash.slice(0, 10)}... failed`, + }); + clearInterval(interval); + pollingRef.current.delete(id); + } + } catch { + // Silently ignore polling errors + } + }, POLL_INTERVAL_MS); + + pollingRef.current.set(id, interval); + }, + [toast] + ); + + const addTransaction = useCallback( + (hash: string, type: TransactionType): string => { + const id = generateId(); + const newTransaction: Transaction = { + id, + hash, + type, + status: 'pending', + timestamp: Date.now(), + }; + + setTransactions((prev) => { + const updated = [newTransaction, ...prev].slice(0, MAX_TRANSACTIONS); + return updated; + }); + + toast({ + type: 'info', + title: 'Transaction pending', + message: `${type.charAt(0).toUpperCase() + type.slice(1)} transaction submitted`, + loading: true, + }); + + pollTransaction(id, hash); + + return id; + }, + [toast, pollTransaction] + ); + + const updateTransactionStatus = useCallback( + (id: string, status: TransactionStatus) => { + setTransactions((prev) => + prev.map((tx) => (tx.id === id ? { ...tx, status } : tx)) + ); + }, + [] + ); + + const clearTransaction = useCallback((id: string) => { + const interval = pollingRef.current.get(id); + if (interval) { + clearInterval(interval); + pollingRef.current.delete(id); + } + setTransactions((prev) => prev.filter((tx) => tx.id !== id)); + }, []); + + const clearAllTransactions = useCallback(() => { + pollingRef.current.forEach((interval) => clearInterval(interval)); + pollingRef.current.clear(); + setTransactions([]); + }, []); + + useEffect(() => { + return () => { + pollingRef.current.forEach((interval) => clearInterval(interval)); + pollingRef.current.clear(); + }; + }, []); + + return ( + + {children} + + ); +} + +export function useTransaction(): TransactionContextValue { + const ctx = useContext(TransactionContext); + if (!ctx) { + throw new Error('useTransaction must be used inside TransactionProvider'); + } + return ctx; +} diff --git a/context/provider.tsx b/context/provider.tsx index 13a59f2..a803944 100644 --- a/context/provider.tsx +++ b/context/provider.tsx @@ -10,6 +10,7 @@ import { http } from 'viem'; import { liskSepolia, mainnet, sepolia } from 'viem/chains'; import { createConfig, WagmiProvider } from 'wagmi'; import WrongNetworkBanner from '@/components/common/WrongNetworkBanner'; +import { TransactionProvider } from '@/context/TransactionContext'; import { LoadingProvider } from '@/context/LoadingContext'; import { UserPreferencesProvider } from '@/context/UserPreferencesContext'; import { WalletProvider } from '@/context/WalletContext'; @@ -57,12 +58,14 @@ const Provider = ({ children }: { children: ReactNode }) => { - - - - {children} - - + + + + + {children} + + + {process.env.NODE_ENV === 'development' && }