← back to Handbag Auth Nextjs

src/components/UnifiedSearch.tsx

369 lines

'use client'

import { useState, useEffect, useCallback, useRef } from 'react'

// Simple debounce utility
function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout | null = null
  return (...args: Parameters<T>) => {
    if (timeout) clearTimeout(timeout)
    timeout = setTimeout(() => func(...args), wait)
  }
}

interface SearchResult {
  type: 'listing' | 'auction' | 'arbitrage' | 'price_history'
  id: string | number
  title: string
  description?: string
  price?: number
  currency?: string
  url?: string
  brand?: string
  model?: string
  source: string
  relevance: number
  metadata: Record<string, any>
  created_at?: string
}

interface UnifiedSearchProps {
  placeholder?: string
  autoFocus?: boolean
  onResultClick?: (result: SearchResult) => void
  showFilters?: boolean
}

export function UnifiedSearch({
  placeholder = "Search across all data sources...",
  autoFocus = false,
  onResultClick,
  showFilters = true
}: UnifiedSearchProps) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<SearchResult[]>([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [selectedSources, setSelectedSources] = useState<string[]>(['all'])
  const [minPrice, setMinPrice] = useState<string>('')
  const [maxPrice, setMaxPrice] = useState<string>('')
  const [showResults, setShowResults] = useState(false)
  const [stats, setStats] = useState<Record<string, number>>({})

  // Debounced search function
  const performSearch = useCallback(
    debounce(async (searchQuery: string, sources: string[], min: string, max: string) => {
      if (searchQuery.length < 2) {
        setResults([])
        setStats({})
        setShowResults(false)
        return
      }

      setLoading(true)
      setError(null)

      try {
        const params = new URLSearchParams({
          q: searchQuery,
          sources: sources.join(','),
          limit: '50'
        })

        if (min) params.append('minPrice', min)
        if (max) params.append('maxPrice', max)

        const response = await fetch(`/api/search/unified?${params.toString()}`)
        const data = await response.json()

        if (data.success) {
          setResults(data.results)
          setStats(data.stats || {})
          setShowResults(true)
        } else {
          setError(data.error || 'Search failed')
          setResults([])
        }
      } catch (err) {
        setError('Search failed. Please try again.')
        setResults([])
      } finally {
        setLoading(false)
      }
    }, 500),
    []
  )

  useEffect(() => {
    performSearch(query, selectedSources, minPrice, maxPrice)
  }, [query, selectedSources, minPrice, maxPrice, performSearch])

  const handleSourceToggle = (source: string) => {
    if (source === 'all') {
      setSelectedSources(['all'])
    } else {
      const newSources = selectedSources.includes('all')
        ? [source]
        : selectedSources.includes(source)
          ? selectedSources.filter(s => s !== source)
          : [...selectedSources, source]

      setSelectedSources(newSources.length === 0 ? ['all'] : newSources)
    }
  }

  const getTypeIcon = (type: string) => {
    switch (type) {
      case 'listing':
        return '🛍️'
      case 'auction':
        return '🔨'
      case 'arbitrage':
        return '💰'
      case 'price_history':
        return '📈'
      default:
        return '📦'
    }
  }

  const getTypeColor = (type: string) => {
    switch (type) {
      case 'listing':
        return 'bg-blue-100 text-blue-800'
      case 'auction':
        return 'bg-purple-100 text-purple-800'
      case 'arbitrage':
        return 'bg-green-100 text-green-800'
      case 'price_history':
        return 'bg-orange-100 text-orange-800'
      default:
        return 'bg-gray-100 text-gray-800'
    }
  }

  const formatPrice = (price?: number, currency?: string) => {
    if (!price) return 'N/A'
    const symbol = currency === 'JPY' ? '¥' : currency === 'HKD' ? 'HK$' : '$'
    return `${symbol}${price.toLocaleString()}`
  }

  return (
    <div className="w-full">
      {/* Search Input */}
      <div className="relative mb-4">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={placeholder}
          autoFocus={autoFocus}
          className="w-full px-4 py-3 pl-12 bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent text-base shadow-sm"
          style={{ fontSize: '16px' }} // Prevent iOS zoom
        />
        <svg
          className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
          />
        </svg>
        {loading && (
          <div className="absolute right-4 top-1/2 transform -translate-y-1/2">
            <div className="animate-spin h-5 w-5 border-2 border-purple-500 border-t-transparent rounded-full"></div>
          </div>
        )}
      </div>

      {/* Filters */}
      {showFilters && (
        <div className="mb-4 space-y-3">
          {/* Source Selection */}
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-2">
              Data Sources
            </label>
            <div className="flex flex-wrap gap-2">
              {['all', 'listings', 'auctions', 'arbitrage', 'price_history'].map(source => (
                <button
                  key={source}
                  onClick={() => handleSourceToggle(source)}
                  className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
                    selectedSources.includes(source) || (selectedSources.includes('all') && source !== 'all')
                      ? 'bg-purple-600 text-white'
                      : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
                  }`}
                >
                  {source.replace('_', ' ').charAt(0).toUpperCase() + source.slice(1).replace('_', ' ')}
                  {stats[source] > 0 && ` (${stats[source]})`}
                </button>
              ))}
            </div>
          </div>

          {/* Price Range */}
          <div className="flex gap-3">
            <div className="flex-1">
              <label className="block text-sm font-medium text-gray-700 mb-1">
                Min Price ($)
              </label>
              <input
                type="number"
                value={minPrice}
                onChange={(e) => setMinPrice(e.target.value)}
                placeholder="0"
                className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
                style={{ fontSize: '16px' }}
              />
            </div>
            <div className="flex-1">
              <label className="block text-sm font-medium text-gray-700 mb-1">
                Max Price ($)
              </label>
              <input
                type="number"
                value={maxPrice}
                onChange={(e) => setMaxPrice(e.target.value)}
                placeholder="No limit"
                className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
                style={{ fontSize: '16px' }}
              />
            </div>
          </div>
        </div>
      )}

      {/* Error Message */}
      {error && (
        <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
          {error}
        </div>
      )}

      {/* Stats Bar */}
      {showResults && results.length > 0 && (
        <div className="mb-4 p-3 bg-gray-50 rounded-lg">
          <div className="flex items-center justify-between text-sm">
            <span className="font-medium text-gray-700">
              Found {results.length} result{results.length !== 1 ? 's' : ''}
            </span>
            <div className="flex gap-3 text-xs">
              {stats.listings > 0 && <span>🛍️ {stats.listings}</span>}
              {stats.auctions > 0 && <span>🔨 {stats.auctions}</span>}
              {stats.arbitrage > 0 && <span>💰 {stats.arbitrage}</span>}
              {stats.price_history > 0 && <span>📈 {stats.price_history}</span>}
            </div>
          </div>
        </div>
      )}

      {/* Results */}
      {showResults && (
        <div className="space-y-2 max-h-[600px] overflow-y-auto">
          {results.length === 0 && !loading && query.length >= 2 && (
            <div className="text-center py-8 text-gray-500">
              No results found for "{query}"
            </div>
          )}

          {results.map((result, index) => (
            <div
              key={`${result.type}-${result.id}-${index}`}
              onClick={() => {
                if (result.url) {
                  window.open(result.url, '_blank')
                }
                onResultClick?.(result)
              }}
              className="bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-500 hover:shadow-md transition-all cursor-pointer"
            >
              <div className="flex items-start gap-3">
                {/* Icon */}
                <div className="text-2xl flex-shrink-0">
                  {getTypeIcon(result.type)}
                </div>

                {/* Content */}
                <div className="flex-1 min-w-0">
                  {/* Type Badge */}
                  <div className="flex items-center gap-2 mb-1">
                    <span className={`px-2 py-0.5 rounded text-xs font-medium ${getTypeColor(result.type)}`}>
                      {result.type.replace('_', ' ').toUpperCase()}
                    </span>
                    {result.metadata.isDeal && (
                      <span className="px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
                        🔥 DEAL {result.metadata.dealPercentage?.toFixed(0)}%
                      </span>
                    )}
                    {result.metadata.verified && (
                      <span className="px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
                        ✓ Verified
                      </span>
                    )}
                  </div>

                  {/* Title */}
                  <h3 className="font-semibold text-gray-900 mb-1 truncate">
                    {result.title}
                  </h3>

                  {/* Description */}
                  {result.description && (
                    <p className="text-sm text-gray-600 mb-2 line-clamp-2">
                      {result.description}
                    </p>
                  )}

                  {/* Metadata */}
                  <div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
                    {result.brand && (
                      <span className="font-medium text-gray-700">{result.brand}</span>
                    )}
                    {result.price !== undefined && (
                      <span className="font-semibold text-purple-600">
                        {formatPrice(result.price, result.currency)}
                      </span>
                    )}
                    <span>{result.source}</span>
                    {result.metadata.profitMarginPct && (
                      <span className="text-green-600 font-medium">
                        +{result.metadata.profitMarginPct.toFixed(1)}% profit
                      </span>
                    )}
                    {result.metadata.savingsPercent > 0 && (
                      <span className="text-green-600 font-medium">
                        {result.metadata.savingsPercent.toFixed(0)}% off estimate
                      </span>
                    )}
                  </div>
                </div>

                {/* Price/Image */}
                {result.metadata.imageUrl && (
                  <div className="flex-shrink-0 w-16 h-16 bg-gray-100 rounded overflow-hidden">
                    <img
                      src={result.metadata.imageUrl}
                      alt={result.title}
                      className="w-full h-full object-cover"
                      loading="lazy"
                    />
                  </div>
                )}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}