updated search suggestions functionality
This commit is contained in:
@@ -179,8 +179,15 @@ export const api = {
|
||||
getProductDetail: (brand, imageId) =>
|
||||
request(`/api/brands/${encodeURIComponent(brand)}/products/${encodeURIComponent(imageId)}`),
|
||||
|
||||
search: (q, { brand, category, top_k = 50 } = {}) =>
|
||||
request(`/api/search${qs({ q, brand, category, top_k })}`),
|
||||
// Same reasoning as SHOW_ALL_PRODUCTS_LIMIT above: searching a brand name must
|
||||
// return that brand's whole catalog, not a page of it. The old default of 50
|
||||
// was in any case ignored - the backend clamped every search to 15.
|
||||
search: (q, { brand, category, top_k = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
|
||||
request(`/api/search${qs({ q, brand, category, top_k, offset })}`),
|
||||
|
||||
// Autocomplete for the catalog search box. Answers from in-process caches,
|
||||
// so it is safe to call per keystroke (debounced in useSuggestions).
|
||||
suggest: (q, { limit = 8 } = {}) => request(`/api/suggest${qs({ q, limit })}`),
|
||||
|
||||
chat: ({ query, brand, category, top_k, history }) =>
|
||||
request('/api/chat', {
|
||||
|
||||
61
src/components/SearchSuggestions.jsx
Normal file
61
src/components/SearchSuggestions.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Tag, LayoutGrid } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The search box's autocomplete listbox.
|
||||
*
|
||||
* Presentational only - the parent owns `activeIndex` and selection, so the
|
||||
* input can drive it from the keyboard while keeping focus.
|
||||
*/
|
||||
export function SearchSuggestions({ suggestions, activeIndex, onHover, onSelect, listboxId }) {
|
||||
const listRef = useRef(null);
|
||||
|
||||
// Keep the keyboard-highlighted option visible when arrowing past the fold.
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0 || !listRef.current) return;
|
||||
const option = listRef.current.querySelector(`[data-index="${activeIndex}"]`);
|
||||
if (option) option.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
if (!suggestions.length) return null;
|
||||
|
||||
return (
|
||||
<ul
|
||||
ref={listRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label="Search suggestions"
|
||||
className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-72 overflow-y-auto rounded-xl border border-ink-900/10 bg-white py-1 shadow-lg"
|
||||
>
|
||||
{suggestions.map((s, i) => {
|
||||
const Icon = s.type === 'category' ? LayoutGrid : Tag;
|
||||
const active = i === activeIndex;
|
||||
return (
|
||||
<li
|
||||
key={`${s.type}:${s.value}`}
|
||||
id={`${listboxId}-opt-${i}`}
|
||||
data-index={i}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
// mousedown, not click: the input's blur would otherwise close the
|
||||
// list before the click could land on it.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(s);
|
||||
}}
|
||||
className={`flex cursor-pointer items-center gap-2.5 px-3.5 py-2 text-xs transition ${
|
||||
active ? 'bg-amber-500/15 text-ink-950' : 'text-ink-900 hover:bg-ink-100/60'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-amber-600' : 'text-slate-400'}`} />
|
||||
<span className="flex-1 truncate font-medium">{s.label}</span>
|
||||
{s.sublabel && (
|
||||
<span className="shrink-0 text-[11px] text-slate-400">{s.sublabel}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,67 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Search, MessageCircle, LayoutGrid } from 'lucide-react';
|
||||
import { NavigationHeader } from './NavigationHeader';
|
||||
import { SearchSuggestions } from './SearchSuggestions';
|
||||
import { useSuggestions } from '../hooks/useSuggestions';
|
||||
|
||||
export function TopBar({
|
||||
mode, onModeChange, query, onQueryChange, onSubmit, onSelectSuggestion,
|
||||
health, onResetHome,
|
||||
}) {
|
||||
// Dropdown state lives here rather than in HomePage so arrowing through the
|
||||
// list doesn't re-render the whole product grid.
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const wrapperRef = useRef(null);
|
||||
const listboxId = useId();
|
||||
|
||||
const { suggestions } = useSuggestions(query, { enabled: mode === 'search' });
|
||||
|
||||
// A new result set invalidates whatever was highlighted.
|
||||
useEffect(() => setActiveIndex(-1), [suggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
function onPointerDown(e) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, []);
|
||||
|
||||
const showList = open && suggestions.length > 0;
|
||||
|
||||
function select(suggestion) {
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
onSelectSuggestion?.(suggestion);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false); // close but keep what was typed
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
setOpen(false); // don't hijack focus movement
|
||||
return;
|
||||
}
|
||||
if (!showList) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault(); // otherwise the caret jumps to the end
|
||||
setActiveIndex((i) => (i + 1) % suggestions.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
|
||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||
// Only intercept Enter when something is highlighted; otherwise the form
|
||||
// submits the raw text exactly as it always has.
|
||||
e.preventDefault();
|
||||
select(suggestions[activeIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health, onResetHome }) {
|
||||
return (
|
||||
<NavigationHeader health={health} onResetHome={onResetHome}>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 pt-1">
|
||||
@@ -12,8 +72,10 @@ export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, hea
|
||||
|
||||
{mode === 'search' && (
|
||||
<form
|
||||
ref={wrapperRef}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
onSubmit();
|
||||
}}
|
||||
className="relative flex-1 w-full"
|
||||
@@ -21,10 +83,35 @@ export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, hea
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search catalog products — e.g. 'Recommend Paneer under ₹150', 'How many products in Cadbury?'"
|
||||
onChange={(e) => { onQueryChange(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={showList}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
showList && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
|
||||
}
|
||||
autoComplete="off"
|
||||
placeholder="Search catalog products — e.g. 'Amul', 'Amul Butter', 'Recommend Paneer under ₹150'"
|
||||
className="w-full rounded-full border border-ink-900/10 bg-white py-2 pl-10 pr-4 text-xs text-ink-950 placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 shadow-xs"
|
||||
/>
|
||||
|
||||
{showList && (
|
||||
<SearchSuggestions
|
||||
suggestions={suggestions}
|
||||
activeIndex={activeIndex}
|
||||
onHover={setActiveIndex}
|
||||
onSelect={select}
|
||||
listboxId={listboxId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Announce the list to screen readers, which can't see it appear. */}
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
{showList ? `${suggestions.length} suggestions available` : ''}
|
||||
</span>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
74
src/hooks/useSuggestions.js
Normal file
74
src/hooks/useSuggestions.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
/**
|
||||
* Debounce a fast-changing value (here, whatever is typed in the search box).
|
||||
* 180ms is roughly two requests per word at 60wpm - responsive without firing
|
||||
* on every keystroke.
|
||||
*/
|
||||
export function useDebouncedValue(value, delay = 180) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand/category suggestions for a partial query.
|
||||
*
|
||||
* Responses can arrive out of order, so a monotonic request id decides which
|
||||
* one is allowed to land - the same guard HomePage uses for search results.
|
||||
* Errors resolve to an empty list rather than propagating: a failing suggest
|
||||
* endpoint must never stop someone typing or submitting a search.
|
||||
*/
|
||||
export function useSuggestions(query, { enabled = true, minLength = 2, limit = 8 } = {}) {
|
||||
const debounced = useDebouncedValue(query);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const requestIdRef = useRef(0);
|
||||
const cacheRef = useRef(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = (debounced || '').trim();
|
||||
|
||||
if (!enabled || trimmed.length < minLength) {
|
||||
requestIdRef.current += 1; // invalidate anything still in flight
|
||||
setSuggestions([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${limit}|${trimmed.toLowerCase()}`;
|
||||
const cached = cacheRef.current.get(cacheKey);
|
||||
if (cached) {
|
||||
// Backspacing back through text already typed shouldn't refetch.
|
||||
requestIdRef.current += 1;
|
||||
setSuggestions(cached);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = ++requestIdRef.current;
|
||||
setLoading(true);
|
||||
|
||||
api.suggest(trimmed, { limit })
|
||||
.then((res) => {
|
||||
const results = res.suggestions || [];
|
||||
cacheRef.current.set(cacheKey, results);
|
||||
if (id === requestIdRef.current) setSuggestions(results);
|
||||
})
|
||||
.catch(() => {
|
||||
if (id === requestIdRef.current) setSuggestions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (id === requestIdRef.current) setLoading(false);
|
||||
});
|
||||
}, [debounced, enabled, minLength, limit]);
|
||||
|
||||
return { suggestions, loading };
|
||||
}
|
||||
@@ -33,6 +33,10 @@ export function HomePage() {
|
||||
const [productsLoading, setProductsLoading] = useState(false);
|
||||
const [productsError, setProductsError] = useState(null);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
// How the backend answered: 'brand_catalog' (a whole brand listing) or
|
||||
// 'hybrid' (ranked matches). `total` is only exact for the former.
|
||||
const [matchMode, setMatchMode] = useState(null);
|
||||
const [total, setTotal] = useState(null);
|
||||
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
|
||||
@@ -87,6 +91,8 @@ export function HomePage() {
|
||||
setProductsLoading(true);
|
||||
setProductsError(null);
|
||||
setHasSearched(false);
|
||||
setMatchMode(null);
|
||||
setTotal(null);
|
||||
const req = selectedBrand
|
||||
? api.getBrandProducts(selectedBrand, { category: selectedCategory })
|
||||
: api.getAllProducts({ category: selectedCategory });
|
||||
@@ -117,9 +123,12 @@ export function HomePage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedBrand, selectedCategory, view]);
|
||||
|
||||
function runSearch() {
|
||||
// Takes an optional override because React batches state updates: calling
|
||||
// this straight after setQuery() would otherwise search the previous text.
|
||||
function runSearch(overrideQuery) {
|
||||
const q = (overrideQuery ?? query).trim();
|
||||
setView('products');
|
||||
if (!query.trim()) {
|
||||
if (!q) {
|
||||
browse();
|
||||
return;
|
||||
}
|
||||
@@ -127,10 +136,12 @@ export function HomePage() {
|
||||
setProductsLoading(true);
|
||||
setProductsError(null);
|
||||
setHasSearched(true);
|
||||
api.search(query.trim(), { brand: selectedBrand, category: selectedCategory })
|
||||
api.search(q, { brand: selectedBrand, category: selectedCategory })
|
||||
.then((res) => {
|
||||
if (id === fetchIdRef.current) {
|
||||
setProducts(res.results || []);
|
||||
setMatchMode(res.match_mode || null);
|
||||
setTotal(res.total ?? null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -145,6 +156,30 @@ export function HomePage() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectSuggestion(s) {
|
||||
// Picking a suggestion is a filter action, not a text search, so clear the
|
||||
// box: the browse effect above is gated on `!query`, and leaving text
|
||||
// behind would both suppress the fetch and leave the header reading
|
||||
// `Results for "..."` for a listing nobody searched for.
|
||||
setQuery('');
|
||||
setHasSearched(false);
|
||||
setMatchMode(null);
|
||||
setTotal(null);
|
||||
setView('products');
|
||||
|
||||
if (s.type === 'category') {
|
||||
// Deliberately does not also set a brand: the effect above clears
|
||||
// selectedCategory whenever selectedBrand changes, which would wipe this.
|
||||
setSelectedCategory(s.value);
|
||||
return;
|
||||
}
|
||||
// Brand: the effect fetches the brand's whole catalog via
|
||||
// /api/brands/{brand}/products, and the sidebar highlights it. That is the
|
||||
// same row set the backend's brand_catalog mode returns for a typed
|
||||
// brand name, so the two routes cannot disagree.
|
||||
setSelectedBrand(s.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
@@ -166,6 +201,7 @@ export function HomePage() {
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onSubmit={runSearch}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
health={health}
|
||||
onResetHome={() => {
|
||||
setSelectedBrand(null);
|
||||
@@ -218,14 +254,20 @@ export function HomePage() {
|
||||
: 'Browsing All Brands'}
|
||||
</p>
|
||||
</div>
|
||||
{products.length > 0 && <p className="text-xs text-slate-500">{products.length} shown</p>}
|
||||
{products.length > 0 && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{total != null && total > products.length
|
||||
? `Showing ${products.length} of ${total}`
|
||||
: `${products.length} shown`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProductGrid
|
||||
products={products}
|
||||
loading={productsLoading}
|
||||
error={productsError}
|
||||
showSimilarity={hasSearched}
|
||||
showSimilarity={hasSearched && matchMode !== 'brand_catalog'}
|
||||
emptyTitle={brands.length === 0 ? 'No catalog data yet' : 'No products found'}
|
||||
emptySubtitle={
|
||||
brands.length === 0
|
||||
|
||||
Reference in New Issue
Block a user