Skip to content
Closed
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
89 changes: 69 additions & 20 deletions components/common/SearchOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,6 +25,7 @@ export default function SearchOverlay() {
const inputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const { suggestions, addSearch, clearHistory } = useSearchHistory();

useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
Expand Down Expand Up @@ -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()) ||
Expand All @@ -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 (
<div
role="dialog"
aria-modal="true"
aria-label="Search overlay"
aria-modal="true"
className="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/80 backdrop-blur-sm"
role="dialog"
onClick={() => setIsOpen(false)}
>
<div
Expand All @@ -109,21 +123,26 @@ export default function SearchOverlay() {
<Search className="w-5 h-5 text-gray-500 mr-3" />
<input
ref={inputRef}
type="text"
role="searchbox"
aria-label="Search Input"
className="flex-1 bg-transparent border-none outline-none text-white text-lg placeholder:text-gray-500"
placeholder="Search tracks, artists..."
role="searchbox"
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedIndex(-1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && query.trim()) {
handleSearchSubmit(query);
}
}}
/>
<button
onClick={() => setIsOpen(false)}
aria-label="Close search overlay"
className="p-1 rounded-full hover:bg-gray-800 text-gray-400 hover:text-white transition-colors"
onClick={() => setIsOpen(false)}
>
<X className="w-5 h-5" />
</button>
Expand All @@ -135,15 +154,13 @@ export default function SearchOverlay() {
filteredResults.map((result, index) => (
<div
key={result.id}
role="option"
aria-selected={index === selectedIndex}
className={`px-4 py-3 flex flex-col cursor-pointer transition-colors ${
index === selectedIndex
? 'bg-gray-800'
: 'hover:bg-gray-800/50'
index === selectedIndex ? 'bg-gray-800' : 'hover:bg-gray-800/50'
}`}
role="option"
onClick={() => {
setIsOpen(false);
handleSearchSubmit(result.title);
}}
onMouseEnter={() => setSelectedIndex(index)}
>
Expand All @@ -153,11 +170,43 @@ export default function SearchOverlay() {
))
) : (
<div className="px-4 py-8 text-center text-gray-500">
No results found for "{query}"
No results found for &quot;{query}&quot;
</div>
)}
</div>
)}

{!query && suggestions.length > 0 && (
<div className="max-h-96 overflow-y-auto py-2" role="listbox">
<div className="px-4 py-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
Recent Searches
</span>
<button
aria-label="Clear search history"
className="text-xs text-gray-500 hover:text-gray-300 flex items-center gap-1 transition-colors"
onClick={clearHistory}
>
<Trash2 className="w-3 h-3" />
Clear
</button>
</div>
{suggestions.map((entry, index) => (
<div
key={entry.query}
aria-selected={index === selectedIndex}
className="px-4 py-3 flex items-center gap-3 cursor-pointer transition-colors hover:bg-gray-800/50"
role="option"
onClick={() => handleSuggestionClick(entry.query)}
onMouseEnter={() => setSelectedIndex(index)}
>
<Clock className="w-4 h-4 text-gray-500 shrink-0" />
<span className="text-gray-300 text-sm flex-1 truncate">{entry.query}</span>
</div>
))}
</div>
)}

<div className="px-4 py-2 border-t border-gray-800 bg-[#0a0a0a] flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
Expand Down
191 changes: 191 additions & 0 deletions context/TransactionContext.tsx
Original file line number Diff line number Diff line change
@@ -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<TransactionContextValue | null>(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<Transaction[]>([]);
const pollingRef = useRef<Map<string, ReturnType<typeof setInterval>>>(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 (
<TransactionContext.Provider
value={{
transactions,
addTransaction,
updateTransactionStatus,
clearTransaction,
clearAllTransactions,
}}
>
{children}
</TransactionContext.Provider>
);
}

export function useTransaction(): TransactionContextValue {
const ctx = useContext(TransactionContext);
if (!ctx) {
throw new Error('useTransaction must be used inside TransactionProvider');
}
return ctx;
}
15 changes: 9 additions & 6 deletions context/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,12 +58,14 @@ const Provider = ({ children }: { children: ReactNode }) => {
<QueryClientProvider client={queryClient}>
<DynamicWagmiConnector>
<WalletProvider>
<LoadingProvider>
<UserPreferencesProvider>
<WrongNetworkBanner />
{children}
</UserPreferencesProvider>
</LoadingProvider>
<TransactionProvider>
<LoadingProvider>
<UserPreferencesProvider>
<WrongNetworkBanner />
{children}
</UserPreferencesProvider>
</LoadingProvider>
</TransactionProvider>
</WalletProvider>
</DynamicWagmiConnector>
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
Expand Down
Loading
Loading