ifixkart-storefront/components/store/LiveSearch.tsx

318 lines
12 KiB
TypeScript

/**
* @component LiveSearch
* @purpose Search bar matching Screenshots 1 & 2 with All Categories selector, search input, and solid blue Search button.
* @dependencies storefrontService, catalogService, lucide-react
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { Loader2, X, ChevronDown, Check, LayoutGrid, Search } from 'lucide-react';
import { storefrontService, LiveSearchResult } from '@/services/api/storefrontService';
import { catalogService, CategoryResponse } from '@/services/api/catalogService';
import { getImageUrl } from '@/lib/utils';
import {
getCategoryMenuItems,
menuItemCategoryParam,
} from '@/components/CategorySidebar';
export function LiveSearch() {
const router = useRouter();
const [query, setQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [categories, setCategories] = useState<CategoryResponse[]>([]);
const [results, setResults] = useState<LiveSearchResult>({ products: [], categories: [], brands: [], services: [] });
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [catOpen, setCatOpen] = useState(false);
const [catQuery, setCatQuery] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const catSearchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
catalogService.getCategories().then(setCategories);
}, []);
useEffect(() => {
if (!query.trim()) {
setResults({ products: [], categories: [], brands: [], services: [] });
setIsOpen(false);
return;
}
setLoading(true);
const timer = setTimeout(async () => {
const data = await storefrontService.getLiveSearch(query, selectedCategory);
setResults(data);
setLoading(false);
setCatOpen(false);
setIsOpen(true);
}, 300);
return () => clearTimeout(timer);
}, [query, selectedCategory]);
const goToResults = () => {
const trimmed = query.trim();
if (!trimmed) return;
const params = new URLSearchParams({ search: trimmed });
if (selectedCategory !== 'all') params.set('category', selectedCategory);
setIsOpen(false);
router.push(`/products?${params.toString()}`);
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setCatOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
setCatOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
const hasResults =
results.products.length > 0 ||
results.categories.length > 0 ||
results.brands.length > 0 ||
results.services.length > 0;
const categoryMenu = getCategoryMenuItems(categories);
const selectedCategoryLabel =
selectedCategory === 'all'
? 'All Categories'
: categoryMenu.find((item) => menuItemCategoryParam(item.href) === selectedCategory)
?.label ||
categories.find((cat) => cat.slug === selectedCategory)?.name ||
'All Categories';
const filteredCategories = catQuery.trim()
? categoryMenu.filter((item) =>
item.label.toLowerCase().includes(catQuery.trim().toLowerCase())
)
: categoryMenu;
useEffect(() => {
if (!catOpen) {
setCatQuery('');
return;
}
const id = window.setTimeout(() => catSearchRef.current?.focus(), 0);
return () => window.clearTimeout(id);
}, [catOpen]);
const pickCategory = (value: string) => {
setSelectedCategory(value);
setCatOpen(false);
};
return (
<div ref={containerRef} className="relative flex-1 max-w-2xl w-full">
<form
onSubmit={(e) => {
e.preventDefault();
goToResults();
}}
className="flex items-center border border-gray-200 rounded-lg bg-white shadow-2xs overflow-visible"
>
<div className="relative hidden sm:block shrink-0">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={catOpen}
onClick={() => {
setCatOpen((open) => !open);
setIsOpen(false);
}}
className={`h-[42px] min-w-[148px] max-w-[190px] px-3 border-r border-gray-200 rounded-l-lg inline-flex items-center gap-2 text-left transition ${
catOpen
? 'bg-primary/5 text-primary'
: 'bg-gray-50 text-[#1a1a1a] hover:bg-gray-100'
}`}
>
<LayoutGrid className="w-3.5 h-3.5 shrink-0 text-primary" />
<span className="flex-1 text-[12px] font-semibold truncate">
{selectedCategoryLabel}
</span>
<ChevronDown
className={`w-3.5 h-3.5 shrink-0 transition-transform ${
catOpen ? 'rotate-180 text-primary' : 'text-gray-400'
}`}
/>
</button>
{catOpen && (
<div className="absolute left-0 top-[calc(100%+8px)] w-[280px] bg-white border border-gray-200 rounded-xl shadow-[0_12px_32px_rgba(15,23,42,0.12)] overflow-hidden z-[130]">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
ref={catSearchRef}
type="text"
value={catQuery}
onChange={(e) => setCatQuery(e.target.value)}
placeholder="Search categories..."
className="w-full h-9 pl-8 pr-3 rounded-lg bg-gray-50 border border-gray-200 text-[12px] text-[#1a1a1a] outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
/>
</div>
</div>
<ul role="listbox" className="max-h-72 overflow-y-auto py-1">
<li>
<button
type="button"
role="option"
aria-selected={selectedCategory === 'all'}
onClick={() => pickCategory('all')}
className={`w-full px-3.5 py-2.5 flex items-center gap-2.5 text-left text-[13px] transition ${
selectedCategory === 'all'
? 'bg-primary/5 text-primary font-semibold'
: 'text-[#1a1a1a] hover:bg-gray-50'
}`}
>
<LayoutGrid className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">All Categories</span>
{selectedCategory === 'all' ? (
<Check className="w-4 h-4 shrink-0" />
) : null}
</button>
</li>
<li className="mx-3 my-1 border-t border-gray-100" />
{filteredCategories.length === 0 ? (
<li className="px-3.5 py-6 text-center text-[12px] text-gray-400">
No categories found
</li>
) : (
filteredCategories.map((item) => {
const slug = menuItemCategoryParam(item.href) || item.id;
const isSelected = selectedCategory === slug;
return (
<li key={item.id}>
<button
type="button"
role="option"
aria-selected={isSelected}
onClick={() => pickCategory(slug)}
className={`w-full px-3.5 py-2.5 flex items-center gap-2.5 text-left text-[13px] transition ${
isSelected
? 'bg-primary/5 text-primary font-semibold'
: 'text-[#1a1a1a] hover:bg-gray-50'
}`}
>
<span className="flex-1 truncate">{item.label}</span>
{isSelected ? (
<Check className="w-4 h-4 shrink-0" />
) : null}
</button>
</li>
);
})
)}
</ul>
</div>
)}
</div>
{/* Search Input Field */}
<div className="relative flex-1 flex items-center">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
setCatOpen(false);
if (query.trim()) setIsOpen(true);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
goToResults();
}
}}
placeholder="Search products..."
className="w-full text-xs text-gray-800 px-4 py-2.5 outline-none placeholder:text-gray-400"
/>
{query && (
<button
type="button"
onClick={() => { setQuery(''); setIsOpen(false); }}
className="p-1 text-gray-400 hover:text-gray-600 mr-2"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Solid Blue Search Button */}
<button
type="submit"
className="bg-primary hover:brightness-95 text-white px-6 py-2.5 font-semibold text-xs transition-colors flex items-center justify-center gap-1.5 cursor-pointer shrink-0 rounded-r-lg h-[42px]"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <span>Search</span>}
</button>
</form>
{/* Live Search Autocomplete Dropdown */}
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-1.5 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden z-50 max-h-96 overflow-y-auto">
{loading && (
<div className="p-4 text-center text-xs text-gray-500 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-[#1877f2]" /> Searching catalog...
</div>
)}
{!loading && !hasResults && (
<div className="p-4 text-center text-xs text-gray-500">
No products found matching &quot;<span className="font-semibold text-gray-800">{query}</span>&quot;
</div>
)}
{!loading && hasResults && (
<div className="p-2 divide-y divide-gray-100 text-xs">
{results.products.slice(0, 5).map((product) => (
<Link
key={product.product_id}
href={`/products/${product.slug}`}
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-md transition-colors"
>
<div className="relative w-10 h-10 rounded border border-gray-100 overflow-hidden bg-gray-50 shrink-0">
{product.images[0]?.image_url ? (
<Image
src={getImageUrl(product.images[0].image_url)}
alt={product.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-gray-100" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-gray-800 truncate">{product.name}</div>
<div className="text-xs text-[#1877f2] font-bold">
{Number(product.variants[0]?.price || 0).toFixed(2)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
)}
</div>
);
}