← 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>
)
}