updated search suggestions functionality

This commit is contained in:
sriram
2026-08-20 12:59:32 +05:30
parent c84f0a9529
commit f5d3d80f42
5 changed files with 281 additions and 10 deletions

View File

@@ -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', {

View 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>
);
}

View File

@@ -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>

View 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 };
}

View File

@@ -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