184 lines
6 KiB
TypeScript
184 lines
6 KiB
TypeScript
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
|
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
import Fuse from 'fuse.js'
|
|
import { useAllProducts } from '../hooks/prominent-api-hooks'
|
|
import { SearchBar } from 'web-components'
|
|
import { ProductCard } from '../components/ProductCard'
|
|
import { SearchX, Loader2, ChevronLeft } from 'lucide-react'
|
|
import { AppLayout } from '../components/AppLayout'
|
|
import AddToCartDialog from '../components/AddToCartDialog'
|
|
import { useCartStore } from '../lib/stores/cart-store'
|
|
|
|
export const Route = createFileRoute('/home/search')({
|
|
component: SearchPage,
|
|
validateSearch: (search: Record<string, string>) => ({
|
|
q: search.q || '',
|
|
}),
|
|
})
|
|
|
|
// Debounce hook for search
|
|
function useDebounce<T>(value: T, delay: number): T {
|
|
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedValue(value)
|
|
}, delay)
|
|
|
|
return () => {
|
|
clearTimeout(timer)
|
|
}
|
|
}, [value, delay])
|
|
|
|
return debouncedValue
|
|
}
|
|
|
|
function SearchPage() {
|
|
const navigate = useNavigate()
|
|
const { q } = useSearch({ from: '/home/search' })
|
|
const [inputQuery, setInputQuery] = useState(q)
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
const { setAddedToCartProduct } = useCartStore()
|
|
|
|
// Debounce the search query for automatic search
|
|
const debouncedQuery = useDebounce(inputQuery, 300)
|
|
|
|
// Focus search input on mount
|
|
useEffect(() => {
|
|
requestAnimationFrame(() => {
|
|
searchInputRef.current?.focus()
|
|
})
|
|
}, [])
|
|
|
|
const { data: productsData, isLoading, error, refetch } = useAllProducts()
|
|
const allProducts = productsData?.products || []
|
|
|
|
// Client-side search filtering using Fuse.js
|
|
const products = useMemo(() => {
|
|
if (!debouncedQuery.trim()) return allProducts
|
|
|
|
const fuse = new Fuse(allProducts, {
|
|
keys: ['name', 'shortDescription'],
|
|
threshold: 0.3,
|
|
includeScore: true,
|
|
shouldSort: true,
|
|
})
|
|
|
|
const fuseResults = fuse.search(debouncedQuery)
|
|
return fuseResults.map((result) => result.item)
|
|
}, [allProducts, debouncedQuery])
|
|
|
|
const handleSearch = useCallback(() => {
|
|
// Search is now automatic via debounce, but keep this for manual submit
|
|
// Update URL with search query
|
|
navigate({
|
|
to: '/home/search',
|
|
search: { q: inputQuery },
|
|
})
|
|
}, [inputQuery, navigate])
|
|
|
|
const handleProductPress = (id: number) => {
|
|
navigate({
|
|
to: '/home/product/$id',
|
|
params: { id: String(id) },
|
|
})
|
|
}
|
|
|
|
const handleAddToCart = (product: any) => {
|
|
setAddedToCartProduct({ productId: product.id, product })
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="flex min-h-full flex-1 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
|
|
<p className="ml-2 font-medium text-gray-500">Loading products...</p>
|
|
</div>
|
|
</AppLayout>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="flex min-h-full flex-1 flex-col items-center justify-center px-4">
|
|
<SearchX className="h-16 w-16 text-gray-300" />
|
|
<p className="mt-4 text-lg font-medium text-gray-900">Failed to load products</p>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="mt-4 rounded-lg bg-brand-600 px-4 py-2 font-medium text-white transition-colors hover:bg-brand-700"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</AppLayout>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AppLayout>
|
|
<div className="flex min-h-full flex-1 flex-col bg-gray-50">
|
|
{/* Search Header */}
|
|
<div className="sticky top-0 z-10 border-b border-gray-100 bg-white px-4 pb-4 pt-4">
|
|
<div className="mx-auto flex w-full max-w-7xl items-center gap-3">
|
|
<button
|
|
onClick={() => navigate({ to: '/home' })}
|
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-700 transition-colors hover:bg-gray-100"
|
|
aria-label="Back to home"
|
|
>
|
|
<ChevronLeft className="h-5 w-5" />
|
|
</button>
|
|
<div className="flex-1">
|
|
<SearchBar
|
|
ref={searchInputRef}
|
|
placeholder="Search products..."
|
|
value={inputQuery}
|
|
onChange={setInputQuery}
|
|
onSearch={handleSearch}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mx-auto mt-3 flex w-full max-w-7xl flex-row items-center justify-between">
|
|
<p className="text-lg font-bold text-gray-900">
|
|
{debouncedQuery ? `Search Results for "${debouncedQuery}"` : 'All Products'}
|
|
</p>
|
|
<p className="text-sm text-gray-500">{products.length} items</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Products Grid */}
|
|
<div className="mx-auto w-full max-w-7xl flex-1 p-4">
|
|
{products.length === 0 ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center px-4 py-12">
|
|
<SearchX className="h-16 w-16 text-gray-300" />
|
|
<p className="mt-4 text-center text-lg font-medium text-gray-500">
|
|
No products found
|
|
</p>
|
|
{debouncedQuery && (
|
|
<p className="mt-2 text-center text-gray-400">
|
|
Try adjusting your search for "{debouncedQuery}"
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
|
{products.map((product: any) => (
|
|
<ProductCard
|
|
key={product.id}
|
|
item={product}
|
|
onPress={() => handleProductPress(product.id)}
|
|
showDeliveryInfo={false}
|
|
miniView={true}
|
|
useAddToCartDialog={true}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<AddToCartDialog />
|
|
</AppLayout>
|
|
)
|
|
}
|