11"use client" ;
22
33import Link from "next/link" ;
4- import { useEffect , useState } from "react" ;
4+ import { useCallback , useEffect , useRef , useState } from "react" ;
55import type { Bundle } from "@/app/api/bundles/route" ;
66
77export default function BundlesPage ( ) {
88 const [ liveBundles , setLiveBundles ] = useState < Bundle [ ] > ( [ ] ) ;
99 const [ allBundles , setAllBundles ] = useState < string [ ] > ( [ ] ) ;
1010 const [ loading , setLoading ] = useState ( true ) ;
1111 const [ error , setError ] = useState < string | null > ( null ) ;
12+ const [ searchHash , setSearchHash ] = useState < string > ( "" ) ;
13+ const [ filteredLiveBundles , setFilteredLiveBundles ] = useState < Bundle [ ] > ( [ ] ) ;
14+ const [ filteredAllBundles , setFilteredAllBundles ] = useState < string [ ] > ( [ ] ) ;
15+ const debounceTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
16+
17+ const filterBundles = useCallback (
18+ async ( searchTerm : string , live : Bundle [ ] , all : string [ ] ) => {
19+ if ( ! searchTerm . trim ( ) ) {
20+ setFilteredLiveBundles ( live ) ;
21+ setFilteredAllBundles ( all ) ;
22+ return ;
23+ }
24+
25+ // Filter live bundles immediately for better UX
26+ const liveBundlesWithTx = live . filter ( ( bundle ) =>
27+ bundle . txnHashes ?. some ( ( hash ) =>
28+ hash . toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) ,
29+ ) ,
30+ ) ;
31+
32+ let allBundlesWithTx : string [ ] = [ ] ;
33+
34+ try {
35+ const response = await fetch ( `/api/txn/${ searchTerm . trim ( ) } ` ) ;
36+
37+ if ( response . ok ) {
38+ const txnData = await response . json ( ) ;
39+ const bundleIds = txnData . bundle_ids || [ ] ;
40+
41+ allBundlesWithTx = all . filter ( ( bundleId ) =>
42+ bundleIds . includes ( bundleId ) ,
43+ ) ;
44+ }
45+ } catch ( err ) {
46+ console . error ( "Error filtering bundles:" , err ) ;
47+ }
48+
49+ // Batch all state updates together to prevent jitter
50+ setFilteredLiveBundles ( liveBundlesWithTx ) ;
51+ setFilteredAllBundles ( allBundlesWithTx ) ;
52+ } ,
53+ [ ] ,
54+ ) ;
1255
1356 useEffect ( ( ) => {
1457 const fetchLiveBundles = async ( ) => {
@@ -56,6 +99,28 @@ export default function BundlesPage() {
5699 return ( ) => clearInterval ( interval ) ;
57100 } , [ ] ) ;
58101
102+ useEffect ( ( ) => {
103+ if ( debounceTimeoutRef . current ) {
104+ clearTimeout ( debounceTimeoutRef . current ) ;
105+ }
106+
107+ if ( ! searchHash . trim ( ) ) {
108+ // No debounce for clearing search
109+ filterBundles ( searchHash , liveBundles , allBundles ) ;
110+ } else {
111+ // Debounce API calls for non-empty search
112+ debounceTimeoutRef . current = setTimeout ( ( ) => {
113+ filterBundles ( searchHash , liveBundles , allBundles ) ;
114+ } , 300 ) ;
115+ }
116+
117+ return ( ) => {
118+ if ( debounceTimeoutRef . current ) {
119+ clearTimeout ( debounceTimeoutRef . current ) ;
120+ }
121+ } ;
122+ } , [ searchHash , liveBundles , allBundles , filterBundles ] ) ;
123+
59124 if ( loading ) {
60125 return (
61126 < div className = "flex flex-col gap-4 p-8" >
@@ -68,18 +133,36 @@ export default function BundlesPage() {
68133 return (
69134 < div className = "flex flex-col gap-8 p-8" >
70135 < div className = "flex flex-col gap-2" >
71- < h1 className = "text-2xl font-bold" > BundleStore (fka Mempool)</ h1 >
136+ < div className = "flex items-center justify-between" >
137+ < h1 className = "text-2xl font-bold" > BundleStore (fka Mempool)</ h1 >
138+ < div className = "flex items-center gap-2" >
139+ < input
140+ type = "text"
141+ placeholder = "Search by transaction hash..."
142+ value = { searchHash }
143+ onChange = { ( e ) => setSearchHash ( e . target . value ) }
144+ className = "px-3 py-2 border rounded-lg bg-white/5 border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-gray-500 dark:placeholder-gray-400 text-sm min-w-[300px]"
145+ />
146+ </ div >
147+ </ div >
72148 { error && (
73149 < div className = "text-sm text-red-600 dark:text-red-400" > { error } </ div >
74150 ) }
75151 </ div >
76152
77153 < div className = "flex flex-col gap-6" >
78154 < section >
79- < h2 className = "text-xl font-semibold mb-4" > Live Bundles</ h2 >
80- { liveBundles . length > 0 ? (
155+ < h2 className = "text-xl font-semibold mb-4" >
156+ Live Bundles
157+ { searchHash . trim ( ) && (
158+ < span className = "text-sm font-normal text-gray-500 ml-2" >
159+ ({ filteredLiveBundles . length } found)
160+ </ span >
161+ ) }
162+ </ h2 >
163+ { filteredLiveBundles . length > 0 ? (
81164 < ul className = "space-y-2" >
82- { liveBundles . map ( ( bundle ) => (
165+ { filteredLiveBundles . map ( ( bundle ) => (
83166 < li key = { bundle . id } >
84167 < Link
85168 href = { `/bundles/${ bundle . id } ` }
@@ -110,16 +193,25 @@ export default function BundlesPage() {
110193 </ ul >
111194 ) : (
112195 < p className = "text-gray-600 dark:text-gray-400" >
113- No live bundles found.
196+ { searchHash . trim ( )
197+ ? "No live bundles found matching this transaction hash."
198+ : "No live bundles found." }
114199 </ p >
115200 ) }
116201 </ section >
117202
118203 < section >
119- < h2 className = "text-xl font-semibold mb-4" > All Bundles</ h2 >
120- { allBundles . length > 0 ? (
204+ < h2 className = "text-xl font-semibold mb-4" >
205+ All Bundles
206+ { searchHash . trim ( ) && (
207+ < span className = "text-sm font-normal text-gray-500 ml-2" >
208+ ({ filteredAllBundles . length } found)
209+ </ span >
210+ ) }
211+ </ h2 >
212+ { filteredAllBundles . length > 0 ? (
121213 < ul className = "space-y-2" >
122- { allBundles . map ( ( bundleId ) => (
214+ { filteredAllBundles . map ( ( bundleId ) => (
123215 < li key = { bundleId } >
124216 < Link
125217 href = { `/bundles/${ bundleId } ` }
@@ -132,7 +224,9 @@ export default function BundlesPage() {
132224 </ ul >
133225 ) : (
134226 < p className = "text-gray-600 dark:text-gray-400" >
135- No bundles found in S3.
227+ { searchHash . trim ( )
228+ ? "No bundles found in S3 matching this transaction hash."
229+ : "No bundles found in S3." }
136230 </ p >
137231 ) }
138232 </ section >
0 commit comments