From f5d3d80f42dedb2bc0d60df1adff163d845456a5 Mon Sep 17 00:00:00 2001 From: sriram Date: Thu, 20 Aug 2026 12:59:32 +0530 Subject: [PATCH] updated search suggestions functionality --- src/api/client.js | 11 +++- src/components/SearchSuggestions.jsx | 61 ++++++++++++++++++ src/components/TopBar.jsx | 93 +++++++++++++++++++++++++++- src/hooks/useSuggestions.js | 74 ++++++++++++++++++++++ src/pages/HomePage.jsx | 52 ++++++++++++++-- 5 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 src/components/SearchSuggestions.jsx create mode 100644 src/hooks/useSuggestions.js diff --git a/src/api/client.js b/src/api/client.js index 1944b99..9f7a61e 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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', { diff --git a/src/components/SearchSuggestions.jsx b/src/components/SearchSuggestions.jsx new file mode 100644 index 0000000..cf5a71a --- /dev/null +++ b/src/components/SearchSuggestions.jsx @@ -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 ( + + ); +} diff --git a/src/components/TopBar.jsx b/src/components/TopBar.jsx index 694bb6a..e5d247a 100644 --- a/src/components/TopBar.jsx +++ b/src/components/TopBar.jsx @@ -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 (
@@ -12,8 +72,10 @@ export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, hea {mode === 'search' && (
{ e.preventDefault(); + setOpen(false); onSubmit(); }} className="relative flex-1 w-full" @@ -21,10 +83,35 @@ export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, hea 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 && ( + + )} + + {/* Announce the list to screen readers, which can't see it appear. */} + + {showList ? `${suggestions.length} suggestions available` : ''} + )}
diff --git a/src/hooks/useSuggestions.js b/src/hooks/useSuggestions.js new file mode 100644 index 0000000..c9c81c9 --- /dev/null +++ b/src/hooks/useSuggestions.js @@ -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 }; +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 391591e..c5f185c 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -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 (
{ setSelectedBrand(null); @@ -218,14 +254,20 @@ export function HomePage() { : 'Browsing All Brands'}

- {products.length > 0 &&

{products.length} shown

} + {products.length > 0 && ( +

+ {total != null && total > products.length + ? `Showing ${products.length} of ${total}` + : `${products.length} shown`} +

+ )}