/ src / components / search / details.tsx
details.tsx
  1  "use client"
  2  import { useState, useMemo } from "react"
  3  import { motion } from "framer-motion"
  4  import { ExternalLink, Loader2 } from "lucide-react"
  5  import { formatBlockSize, formatBlockTime, getBlockTransactionsBatch } from '@/services/api/main'
  6  import { getRollupName, getAllRollups } from '@/services/api/resolver'
  7  interface Transaction {
  8    hash: string;
  9    index: number;
 10    namespace: number;
 11  }
 12  
 13  interface SearchResult {
 14    type: 'block' | 'transaction' | 'rollup' | 'namespace' | 'block_hash' | 'error' | 'loading'
 15    data: unknown
 16    query: string
 17    displayText?: string
 18  }
 19  
 20  interface SearchDetailsProps {
 21    selectedResults: SearchResult[]
 22  }
 23  
 24  // Foundation component interfaces
 25  interface DetailHeaderProps {
 26    children: React.ReactNode
 27    variant?: 'default' | 'error'
 28  }
 29  
 30  interface FieldRowProps {
 31    label: string
 32    value: string | React.ReactNode
 33    copyable?: string
 34    className?: string
 35  }
 36  
 37  interface HashDisplayProps {
 38    label: string
 39    hash: string
 40    className?: string
 41  }
 42  
 43  interface InfoGridProps {
 44    children: React.ReactNode
 45    className?: string
 46  }
 47  
 48  interface ExternalLinkProps {
 49    href: string
 50    children: React.ReactNode
 51    className?: string
 52  }
 53  
 54  const CopyButton = ({ text, label = "Copy" }: { text: string; label?: string }) => {
 55    const [copied, setCopied] = useState(false);
 56    
 57    const handleCopy = async () => {
 58      try {
 59        // Try modern clipboard API first
 60        if (navigator.clipboard && navigator.clipboard.writeText) {
 61          await navigator.clipboard.writeText(text);
 62          setCopied(true);
 63          setTimeout(() => setCopied(false), 2000);
 64        } else {
 65          // Fallback for HTTP or older browsers
 66          const textArea = document.createElement('textarea');
 67          textArea.value = text;
 68          textArea.style.position = 'fixed';
 69          textArea.style.left = '-999999px';
 70          textArea.style.top = '-999999px';
 71          document.body.appendChild(textArea);
 72          textArea.focus();
 73          textArea.select();
 74          
 75          try {
 76            const successful = document.execCommand('copy');
 77            if (successful) {
 78              setCopied(true);
 79              setTimeout(() => setCopied(false), 2000);
 80            }
 81          } finally {
 82            document.body.removeChild(textArea);
 83          }
 84        }
 85      } catch (err) {
 86        console.error('Failed to copy:', err);
 87        // Still try the fallback method
 88        try {
 89          const textArea = document.createElement('textarea');
 90          textArea.value = text;
 91          textArea.style.position = 'fixed';
 92          textArea.style.left = '-999999px';
 93          document.body.appendChild(textArea);
 94          textArea.select();
 95          document.execCommand('copy');
 96          document.body.removeChild(textArea);
 97          setCopied(true);
 98          setTimeout(() => setCopied(false), 2000);
 99        } catch (fallbackErr) {
100          console.error('Fallback copy also failed:', fallbackErr);
101        }
102      }
103    };
104    
105    return (
106      <button 
107        onClick={handleCopy}
108        className="relative text-gray-400 hover:text-gray-600 transition-colors"
109        title={copied ? "Copied!" : `Copy ${label}`}
110      >
111        {copied ? (
112          <svg className="h-3 w-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
114          </svg>
115        ) : (
116          <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
117            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
118          </svg>
119        )}
120        {copied && (
121          <span className="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-50">
122            Copied!
123          </span>
124        )}
125      </button>
126    );
127  }
128  
129  // Foundation Components
130  const DetailHeader = ({ children, variant = 'default' }: DetailHeaderProps) => (
131    <h3 className={`text-lg font-medium mb-4 ${
132      variant === 'error' ? 'text-red-600' : 'text-gray-900'
133    }`}>{children}</h3>
134  )
135  
136  const FieldRow = ({ label, value, copyable, className = "" }: FieldRowProps) => (
137    <div className={`flex items-center gap-2 ${className}`}>
138      <span className="text-gray-500">{label}:</span>
139      <span className="ml-3 font-mono text-sm text-gray-900">{value}</span>
140      {copyable && <CopyButton text={copyable} label={label.toLowerCase()} />}
141    </div>
142  )
143  
144  const HashDisplay = ({ label, hash, className = "" }: HashDisplayProps) => (
145    <div className={`flex items-center gap-2 ${className}`}>
146      <span className="text-gray-500">{label}:</span>
147      <span className="ml-3 font-mono text-sm text-gray-900 break-all">{hash}</span>
148      <CopyButton text={hash} label={label.toLowerCase()} />
149    </div>
150  )
151  
152  const InfoGrid = ({ children, className = "" }: InfoGridProps) => (
153    <div className={`grid grid-cols-1 md:grid-cols-2 gap-3 text-sm ${className}`}>
154      {children}
155    </div>
156  )
157  
158  const ExternalLinkComponent = ({ href, children, className = "" }: ExternalLinkProps) => (
159    <a 
160      href={href}
161      target="_blank"
162      rel="noopener noreferrer"
163      className={`text-blue-600 hover:text-blue-800 underline flex items-center gap-1 ${className}`}
164    >
165      {children}
166      <ExternalLink className="w-3 h-3" />
167    </a>
168  )
169  
170  export default function SearchDetails({ selectedResults }: SearchDetailsProps) {
171    if (selectedResults.length === 0) return null
172  
173    return (
174      <motion.div
175        initial={{ opacity: 0, y: 20 }}
176        animate={{ opacity: 1, y: 0 }}
177        className="space-y-4"
178      >
179        {selectedResults.map((result, index) => (
180          <div key={index} className="bg-white border border-gray-200 shadow-sm p-6">
181            {(result.type === 'block' || result.type === 'block_hash') && (
182              <BlockDetails result={result} />
183            )}
184  
185            {result.type === 'transaction' && (
186              <TransactionDetails result={result} />
187            )}
188  
189            {result.type === 'namespace' && (
190              <NamespaceDetails result={result} />
191            )}
192  
193            {result.type === 'rollup' && (
194              <RollupDetails result={result} />
195            )}
196  
197            {result.type === 'error' && (
198              <ErrorDetails result={result} />
199            )}
200          </div>
201        ))}
202      </motion.div>
203    )
204  }
205  
206  function BlockDetails({ result }: { result: SearchResult }) {
207    const [showTransactions, setShowTransactions] = useState(false)
208    const [batchState, setBatchState] = useState({
209      loading: false,
210      progress: 0,
211      total: 0,
212      transactions: [] as Transaction[],
213      namespaces: new Set<number>(),
214      error: null as string | null
215    })
216    const [activeFilter, setActiveFilter] = useState<number | null>(null)
217  
218    const blockHeight = (result.data as any)?.height || 0
219    const numTransactions = (result.data as any)?.transactions || (result.data as any)?.num_transactions || 0
220  
221    const handleToggleTransactions = async () => {
222      if (!showTransactions && batchState.transactions.length === 0 && numTransactions > 0) {
223        setBatchState(prev => ({ ...prev, loading: true, error: null, progress: 0, total: 0 }))
224        
225        const blockTransactions = await getBlockTransactionsBatch(blockHeight)
226        const namespaces = new Set(blockTransactions.map(tx => tx.namespace))
227        
228        setBatchState(prev => ({
229          ...prev,
230          loading: false,
231          transactions: blockTransactions,
232          namespaces,
233          progress: 1,
234          total: 1
235        }))
236      }
237      setShowTransactions(!showTransactions)
238    }
239  
240    const sortedTransactions = useMemo(() => {
241      return batchState.transactions.sort((a, b) => a.index - b.index)
242    }, [batchState.transactions])
243  
244    const filteredTransactions = activeFilter !== null 
245      ? sortedTransactions.filter(tx => tx.namespace === activeFilter)
246      : sortedTransactions
247  
248    const namespaceGroups = batchState.transactions.reduce((acc, tx) => {
249      acc[tx.namespace] = (acc[tx.namespace] || 0) + 1
250      return acc
251    }, {} as Record<number, number>)
252  
253    const uniqueNamespaces = batchState.namespaces.size
254  
255    return (
256      <div className="space-y-6">
257        <DetailHeader>
258          Block #{String(blockHeight || 'Unknown')}
259        </DetailHeader>
260  
261        <HashDisplay 
262          label="Hash" 
263          hash={(result.data as any)?.hash || 'N/A'}
264        />
265  
266        <InfoGrid>
267          <FieldRow 
268            label="Height" 
269            value={blockHeight.toString()}
270          />
271          <FieldRow 
272            label="Timestamp" 
273            value={new Date(((result.data as any)?.timestamp || 0) * 1000).toLocaleString()}
274          />
275          <FieldRow 
276            label="Size" 
277            value={(result.data as any)?.human_readable_size || 
278                   ((result.data as any)?.size ? `${(result.data as any).size} bytes` : 'N/A')}
279          />
280          <FieldRow 
281            label="Fee" 
282            value={`${(result.data as any)?.fee_info?.amount 
283              ? (parseInt((result.data as any).fee_info.amount, 10) / 1e18).toFixed(6)
284              : '0.000000'
285            } ETH`}
286          />
287        </InfoGrid>
288  
289        {numTransactions > 0 && (
290          <div>
291            <button
292              onClick={handleToggleTransactions}
293              disabled={batchState.loading}
294              className="flex items-center justify-between w-full text-sm text-gray-900 hover:text-gray-700 transition-colors disabled:opacity-50"
295              aria-expanded={showTransactions}
296              aria-controls="transaction-list"
297            >
298              <span>Transactions ({numTransactions}) {Array.from(' ').map((_, i) => (
299                <span key={i} className="inline-block w-6 border-b border-gray-300 mx-1"></span>
300              ))} {showTransactions ? '▼' : '▶'} Details</span>
301            </button>
302  
303            {batchState.loading && (
304              <div className="text-sm text-gray-500 mt-2 flex items-center gap-2">
305                <Loader2 className="h-3 w-3 animate-spin" />
306                Loading transactions...
307              </div>
308            )}
309  
310            {showTransactions && (
311              <motion.div
312                id="transaction-list"
313                initial={{ opacity: 0, height: 0 }}
314                animate={{ opacity: 1, height: 'auto' }}
315                exit={{ opacity: 0, height: 0 }}
316                className="mt-4 space-y-3"
317              >
318                {batchState.error ? (
319                  <div className="text-red-600 text-sm">
320                    Error loading transactions: {batchState.error}
321                  </div>
322                ) : batchState.transactions.length > 0 ? (
323                  <div className="space-y-3">
324                    {batchState.namespaces.size > 1 && (
325                      <div className="flex flex-wrap gap-2 pb-3 border-b border-gray-200">
326                        <button
327                          onClick={() => setActiveFilter(null)}
328                          className={`text-xs px-2 py-1 rounded transition-colors ${
329                            activeFilter === null 
330                              ? 'bg-blue-100 text-blue-800' 
331                              : 'bg-gray-100 hover:bg-gray-200 text-gray-700'
332                          }`}
333                        >
334                          All
335                        </button>
336                        {Array.from(batchState.namespaces).sort((a, b) => a - b).map(ns => (
337                          <button
338                            key={ns}
339                            onClick={() => setActiveFilter(ns)}
340                            className={`text-xs px-2 py-1 rounded transition-colors ${
341                              activeFilter === ns 
342                                ? 'bg-blue-100 text-blue-800' 
343                                : 'bg-gray-100 hover:bg-gray-200 text-gray-700'
344                            }`}
345                          >
346                            {(() => {
347                              const rollupName = getRollupName(ns)
348                              return rollupName ? `${rollupName} (${namespaceGroups[ns]})` : `NS: ${ns} (${namespaceGroups[ns]})`
349                            })()}
350                          </button>
351                        ))}
352                      </div>
353                    )}
354                    
355                    <div className="space-y-2 max-h-64 overflow-y-auto">
356                      {filteredTransactions.length === 0 && !batchState.loading ? (
357                        <div className="text-center py-4 text-gray-500 text-sm">
358                          No transactions found in this block
359                        </div>
360                      ) : (
361                        filteredTransactions.map((tx, index) => (
362                          <div key={index} className="flex items-center gap-3 py-2 text-sm border-b border-gray-100 last:border-b-0">
363                            <span className="text-gray-500 font-mono">#{tx.index}:</span>
364                            <span className="font-mono text-xs text-gray-900 flex-1 min-w-0 truncate">
365                              {tx.hash.startsWith('TX~') ? tx.hash : `TX~${tx.hash}`}
366                            </span>
367                            <CopyButton text={tx.hash} label="transaction hash" />
368                            <span className="text-gray-400">|</span>
369                            <span className="text-gray-900">
370                              {(() => {
371                                const rollupName = getRollupName(tx.namespace)
372                                return rollupName ? `${rollupName} (${tx.namespace})` : tx.namespace
373                              })()}
374                            </span>
375                          </div>
376                        ))
377                      )}
378                    </div>
379  
380                    <div className="text-sm text-gray-600 pt-2">
381                      {activeFilter !== null ? (
382                        <>Showing {filteredTransactions.length} transactions from {(() => {
383                          const rollupName = getRollupName(activeFilter)
384                          return rollupName ? `${rollupName} (${activeFilter})` : `namespace ${activeFilter}`
385                        })()}</>
386                      ) : (
387                        <>{uniqueNamespaces} {uniqueNamespaces === 1 ? 'namespace' : 'namespaces'}, {batchState.transactions.length} total transactions</>
388                      )}
389                    </div>
390                  </div>
391                ) : (
392                  <div className="text-gray-500 text-sm">No transaction data available</div>
393                )}
394              </motion.div>
395            )}
396          </div>
397        )}
398      </div>
399    )
400  }
401  
402  function TransactionDetails({ result }: { result: SearchResult }) {
403    // Type guards and data extraction
404    const hasTransactionData = result.data && typeof result.data === 'object' && 'transaction' in result.data
405    const transactionData = hasTransactionData ? (result.data as any) : null
406    const transaction = transactionData?.transaction
407    
408    // Extract values with proper fallbacks
409    const getTransactionIndex = () => {
410      if (transactionData?.index !== undefined) return transactionData.index
411      if (transaction?.index !== undefined) return transaction.index
412      return '?'
413    }
414    
415    const getNamespaceDisplay = () => {
416      if (transaction?.namespace === undefined) return null
417      const namespace = transaction.namespace
418      const rollupName = getRollupName(namespace)
419      return rollupName ? `${namespace} (${rollupName})` : namespace
420    }
421  
422    return (
423      <div className="space-y-4">
424        <DetailHeader>Transaction Details</DetailHeader>
425        
426        <div className="space-y-4">
427          <HashDisplay 
428            label="Transaction Hash" 
429            hash={result.query}
430          />
431          
432          {hasTransactionData ? (
433            <>
434              <InfoGrid>
435                <FieldRow 
436                  label="Size" 
437                  value={transaction?.tx_size_bytes ? formatBlockSize(transaction.tx_size_bytes) : 'Unknown'}
438                />
439                <FieldRow 
440                  label="Block Height" 
441                  value={transactionData.block_height || 'Unknown'}
442                />
443                <FieldRow 
444                  label="Index" 
445                  value={`#${getTransactionIndex()}`}
446                />
447                {transaction?.namespace !== undefined && (
448                  <FieldRow 
449                    label="Namespace" 
450                    value={getNamespaceDisplay() || 'Unknown'}
451                  />
452                )}
453              </InfoGrid>
454              
455              {transaction?.block_hash && (
456                <HashDisplay 
457                  label="Block Hash" 
458                  hash={transaction.block_hash}
459                />
460              )}
461              
462              {transaction?.timestamp && (
463                <FieldRow 
464                  label="Timestamp" 
465                  value={formatBlockTime(transaction.timestamp) || 'Unknown'}
466                />
467              )}
468              
469              {transaction?.human_readable_time && (
470                <FieldRow 
471                  label="Time Ago" 
472                  value={transaction.human_readable_time}
473                />
474              )}
475            </>
476          ) : null}
477        </div>
478      </div>
479    )
480  }
481  
482  function NamespaceDetails({ result }: { result: SearchResult }) {
483    const namespaceId = parseInt(result.query)
484    const rollupData = getAllRollups().find(rollup => rollup.namespace === namespaceId)
485    
486    if (rollupData) {
487      return (
488        <div className="space-y-4">
489          <DetailHeader>{rollupData.name}</DetailHeader>
490          
491          <InfoGrid>
492            <FieldRow 
493              label="Namespace" 
494              value={rollupData.namespace.toString()}
495            />
496            <FieldRow 
497              label="Website" 
498              value={<ExternalLinkComponent href={rollupData.website} className="break-all">{rollupData.website}</ExternalLinkComponent>}
499            />
500            <FieldRow 
501              label="Scan" 
502              value={<ExternalLinkComponent href={rollupData.scan} className="break-all">{rollupData.scan}</ExternalLinkComponent>}
503            />
504          </InfoGrid>
505        </div>
506      )
507    } else {
508      return (
509        <div className="space-y-4">
510          <DetailHeader>Namespace #{result.query}</DetailHeader>
511          
512          <InfoGrid>
513            <FieldRow 
514              label="Namespace ID" 
515              value={result.query}
516            />
517            <FieldRow 
518              label="Status" 
519              value="No associated rollup found"
520            />
521          </InfoGrid>
522        </div>
523      )
524    }
525  }
526  
527  function RollupDetails({ result }: { result: SearchResult }) {
528    return (
529      <div className="space-y-4">
530        <DetailHeader>Rollup Search Results</DetailHeader>
531        
532        {Array.isArray(result.data) && (result.data as any[]).length > 0 ? (
533          <div className="space-y-4">
534            {(result.data as any[]).map((rollup, rollupIndex) => (
535              <div key={rollupIndex} className="border-l-4 border-blue-400 pl-4">
536                <InfoGrid>
537                  <FieldRow 
538                    label="Name" 
539                    value={rollup.name}
540                  />
541                  <FieldRow 
542                    label="Namespace" 
543                    value={rollup.namespace}
544                  />
545                  <FieldRow 
546                    label="Website" 
547                    value={<ExternalLinkComponent href={rollup.website} className="break-all">{rollup.website}</ExternalLinkComponent>}
548                  />
549                  <FieldRow 
550                    label="Scan" 
551                    value={<ExternalLinkComponent href={rollup.scan} className="break-all">{rollup.scan}</ExternalLinkComponent>}
552                  />
553                </InfoGrid>
554              </div>
555            ))}
556          </div>
557        ) : (
558          <div className="text-gray-500">No rollups found for "{result.query}"</div>
559        )}
560      </div>
561    )
562  }
563  
564  function ErrorDetails({ result }: { result: SearchResult }) {
565    return (
566      <div className="space-y-4">
567        <DetailHeader variant="error">Search Error</DetailHeader>
568        <div className="text-sm text-red-500">
569          {(result.data as any)?.error}
570        </div>
571      </div>
572    )
573  }