318 lines
12 KiB
TypeScript
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 "<span className="font-semibold text-gray-800">{query}</span>"
|
|
</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>
|
|
);
|
|
}
|