diff --git a/frontend/package.json b/frontend/package.json index 61a2806e..8f82386c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,8 +36,10 @@ "ethers": "^6.13.5", "framer-motion": "^12.23.12", "html2canvas": "^1.4.1", + "idb-keyval": "^6.2.1", "jspdf": "^3.0.0", "lucide-react": "^0.471.1", + "papaparse": "^5.5.2", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/ProductAutocompleteInput.jsx b/frontend/src/components/ProductAutocompleteInput.jsx new file mode 100644 index 00000000..86c55be3 --- /dev/null +++ b/frontend/src/components/ProductAutocompleteInput.jsx @@ -0,0 +1,218 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Input } from '@/components/ui/input'; + +const PAGE_SIZE = 12; +const DEBOUNCE_MS = 250; +const normalizeSearchText = (text) => String(text || '').trim().toLocaleLowerCase(); + +export default function ProductAutocompleteInput({ + value, + onChange, + onSelectProduct, + catalogMetadata, + placeholder, + className, + name, + inputRef, +}) { + const [showSuggestions, setShowSuggestions] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [activeIndex, setActiveIndex] = useState(-1); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [totalMatches, setTotalMatches] = useState(0); + const wrapperRef = useRef(null); + const internalInputRef = useRef(null); + const listRef = useRef(null); + const listId = useRef(`autocomplete-list-${Math.random().toString(36).slice(2, 9)}`); + + const setRefs = useCallback( + (el) => { + internalInputRef.current = el; + if (typeof inputRef === 'function') { + inputRef(el); + } else if (inputRef) { + inputRef.current = el; + } + }, + [inputRef], + ); + + useEffect(() => { + const handleClickOutside = (event) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { + setShowSuggestions(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const catalogData = catalogMetadata?.data; + const hasCatalog = Array.isArray(catalogData) && catalogData.length > 0; + + useEffect(() => { + if (!hasCatalog) { + setSuggestions([]); + setActiveIndex(-1); + setTotalMatches(0); + return; + } + + const timeoutId = setTimeout(() => { + const searchTerm = normalizeSearchText(value); + const baseList = searchTerm + ? catalogData.filter((product) => { + const productName = normalizeSearchText(product.name || product.description); + return productName.includes(searchTerm); + }) + : catalogData; + + setTotalMatches(baseList.length); + setSuggestions(baseList.slice(0, visibleCount)); + setActiveIndex(-1); + }, DEBOUNCE_MS); + + return () => clearTimeout(timeoutId); + }, [value, visibleCount, catalogData, hasCatalog]); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [value]); + + const scrollActiveIntoView = useCallback((index) => { + if (!listRef.current) return; + const items = listRef.current.querySelectorAll('[role="option"]'); + if (items[index]) { + items[index].scrollIntoView({ block: 'nearest' }); + } + }, []); + + useEffect(() => { + scrollActiveIntoView(activeIndex); + }, [activeIndex, scrollActiveIntoView]); + + const handleInputChange = useCallback( + (e) => { + onChange(e); + setShowSuggestions(true); + }, + [onChange], + ); + + const handleSelect = useCallback( + (product) => { + setShowSuggestions(false); + setActiveIndex(-1); + onSelectProduct(product); + }, + [onSelectProduct], + ); + + const handleKeyDown = useCallback( + (e) => { + if (e.key === 'Escape') { + setShowSuggestions(false); + setActiveIndex(-1); + return; + } + + if (e.key === 'Enter' && showSuggestions) { + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < suggestions.length) { + handleSelect(suggestions[activeIndex]); + } + return; + } + + if (!showSuggestions || suggestions.length === 0) { + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => { + const next = prev < suggestions.length - 1 ? prev + 1 : prev; + return next; + }); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => { + const next = prev > 0 ? prev - 1 : 0; + return next; + }); + } + }, + [showSuggestions, suggestions, activeIndex, handleSelect, scrollActiveIntoView], + ); + + const productKey = useCallback( + (product, idx) => `${normalizeSearchText(product.name || product.description)}-${product.price}-${idx}`, + [], + ); + + const showNoResults = showSuggestions && suggestions.length === 0 && hasCatalog && value?.trim(); + + return ( +
+ URL fetch works without saving. Enable the checkbox to store the last URL in IndexedDB for one-click refresh later. +
++ Search always uses the currently loaded catalog. Importing a new source replaces the active search dataset. +
+ {hasSavedUrlSource && ( ++ Saved URL: {savedUrl} +
+ )} +Required: name (or description), price (or unit_price)
+Optional: tax, discount, qty
+Autocomplete shows default rows with a limit and Load more. Search filters the same catalog for large datasets.
+Imported catalog is cached in IndexedDB and memory for faster autocomplete.
+