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 (
+
+ {suggestions.map((s, i) => {
+ const Icon = s.type === 'category' ? LayoutGrid : Tag;
+ const active = i === activeIndex;
+ return (
+ - 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'
+ }`}
+ >
+
+ {s.label}
+ {s.sublabel && (
+ {s.sublabel}
+ )}
+
+ );
+ })}
+
+ );
+}
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' && (
)}
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`}
+
+ )}