Product editor search enhancements

This commit is contained in:
2026-03-11 15:23:03 -04:00
parent c344fdc3b8
commit f887dc6af1
4 changed files with 193 additions and 60 deletions

View File

@@ -1058,7 +1058,16 @@ router.get('/search-products', async (req, res) => {
// Build WHERE clause with additional filters // Build WHERE clause with additional filters
let whereClause; let whereClause;
if (pid) { if (pid) {
whereClause = `\n WHERE p.pid = ${connection.escape(Number(pid))}`; const pids = String(pid).split(',').map(Number).filter(n => !isNaN(n) && n > 0);
if (pids.length === 0) {
connection.release();
return res.status(400).json({ error: 'Invalid pid parameter' });
}
if (pids.length === 1) {
whereClause = `\n WHERE p.pid = ${connection.escape(pids[0])}`;
} else {
whereClause = `\n WHERE p.pid IN (${pids.map(p => connection.escape(p)).join(',')})`;
}
} else { } else {
whereClause = ` whereClause = `
WHERE ( WHERE (

View File

@@ -473,10 +473,14 @@ router.get('/search', async (req, res) => {
return [like, like, like, like, like]; return [like, like, like, like, like];
}); });
const { rows } = await pool.query(` const whereClause = conditions.join(' AND ');
const searchParams = [...params, `%${q.trim()}%`];
const [{ rows }, { rows: countRows }] = await Promise.all([
pool.query(`
SELECT pid, title, sku, barcode, brand, line, regular_price, image_175 SELECT pid, title, sku, barcode, brand, line, regular_price, image_175
FROM products p FROM products p
WHERE ${conditions.join(' AND ')} WHERE ${whereClause}
ORDER BY ORDER BY
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0 CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
WHEN p.barcode ILIKE $${params.length + 1} THEN 1 WHEN p.barcode ILIKE $${params.length + 1} THEN 1
@@ -485,9 +489,15 @@ router.get('/search', async (req, res) => {
END, END,
p.total_sold DESC NULLS LAST p.total_sold DESC NULLS LAST
LIMIT 50 LIMIT 50
`, [...params, `%${q.trim()}%`]); `, searchParams),
pool.query(`
SELECT COUNT(*)::int AS total
FROM products p
WHERE ${whereClause}
`, params),
]);
res.json(rows); res.json({ results: rows, total: countRows[0].total });
} catch (error) { } catch (error) {
console.error('Error searching products:', error); console.error('Error searching products:', error);
res.status(500).json({ error: 'Search failed' }); res.status(500).json({ error: 'Search failed' });

View File

@@ -12,10 +12,16 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area"; import {
import { Loader2, Search } from "lucide-react"; Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Check, ChevronDown, Loader2, Search } from "lucide-react";
import type { SearchProduct } from "./types"; import type { SearchProduct } from "./types";
const SEARCH_LIMIT = 50;
interface QuickSearchResult { interface QuickSearchResult {
pid: number; pid: number;
title: string; title: string;
@@ -29,31 +35,43 @@ interface QuickSearchResult {
export function ProductSearch({ export function ProductSearch({
onSelect, onSelect,
onLoadAll,
onNewSearch,
loadedPids,
}: { }: {
onSelect: (product: SearchProduct) => void; onSelect: (product: SearchProduct) => void;
onLoadAll: (pids: number[]) => void;
onNewSearch: () => void;
loadedPids: Set<number>;
}) { }) {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]); const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null); const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
const [resultsOpen, setResultsOpen] = useState(false);
const handleSearch = useCallback(async () => { const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return; if (!searchTerm.trim()) return;
setIsSearching(true); setIsSearching(true);
onNewSearch();
try { try {
const res = await axios.get("/api/products/search", { const res = await axios.get("/api/products/search", {
params: { q: searchTerm }, params: { q: searchTerm },
}); });
setSearchResults(res.data); setSearchResults(res.data.results);
setTotalCount(res.data.total);
setResultsOpen(true);
} catch { } catch {
toast.error("Search failed"); toast.error("Search failed");
} finally { } finally {
setIsSearching(false); setIsSearching(false);
} }
}, [searchTerm]); }, [searchTerm, onNewSearch]);
const handleSelect = useCallback( const handleSelect = useCallback(
async (product: QuickSearchResult) => { async (product: QuickSearchResult) => {
if (loadedPids.has(Number(product.pid))) return;
setIsLoadingProduct(product.pid); setIsLoadingProduct(product.pid);
try { try {
const res = await axios.get("/api/import/search-products", { const res = await axios.get("/api/import/search-products", {
@@ -62,7 +80,7 @@ export function ProductSearch({
const full = (res.data as SearchProduct[])[0]; const full = (res.data as SearchProduct[])[0];
if (full) { if (full) {
onSelect(full); onSelect(full);
setSearchResults([]); setResultsOpen(false);
} else { } else {
toast.error("Could not load full product details"); toast.error("Could not load full product details");
} }
@@ -72,9 +90,23 @@ export function ProductSearch({
setIsLoadingProduct(null); setIsLoadingProduct(null);
} }
}, },
[onSelect] [onSelect, loadedPids]
); );
const handleLoadAll = useCallback(() => {
const pids = searchResults
.map((r) => r.pid)
.filter((pid) => !loadedPids.has(Number(pid)));
if (pids.length === 0) return;
onLoadAll(pids);
setResultsOpen(false);
}, [searchResults, loadedPids, onLoadAll]);
const unloadedCount = searchResults.filter(
(r) => !loadedPids.has(Number(r.pid))
).length;
const isTruncated = totalCount > SEARCH_LIMIT;
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -98,29 +130,57 @@ export function ProductSearch({
</div> </div>
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<div className="mt-4 border rounded-md"> <Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-4">
<ScrollArea className="max-h-80"> <div className="flex items-center justify-between">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 px-2 text-muted-foreground">
<ChevronDown
className={`h-4 w-4 transition-transform ${resultsOpen ? "" : "-rotate-90"}`}
/>
{isTruncated
? `Showing ${SEARCH_LIMIT} of ${totalCount} results`
: `${totalCount} ${totalCount === 1 ? "result" : "results"}`}
</Button>
</CollapsibleTrigger>
{unloadedCount > 0 && (
<Button variant="outline" size="sm" onClick={handleLoadAll}>
Load all results
</Button>
)}
</div>
<CollapsibleContent>
<div className="border rounded-md mt-2 max-h-80 overflow-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead className="sticky top-0 bg-background">Name</TableHead>
<TableHead>SKU</TableHead> <TableHead className="sticky top-0 bg-background">SKU</TableHead>
<TableHead>Brand</TableHead> <TableHead className="sticky top-0 bg-background">Brand</TableHead>
<TableHead>Line</TableHead> <TableHead className="sticky top-0 bg-background">Line</TableHead>
<TableHead className="text-right">Price</TableHead> <TableHead className="sticky top-0 bg-background text-right">
Price
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{searchResults.map((product) => ( {searchResults.map((product) => {
const isLoaded = loadedPids.has(Number(product.pid));
return (
<TableRow <TableRow
key={product.pid} key={product.pid}
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`} className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
onClick={() => !isLoadingProduct && handleSelect(product)} onClick={() =>
!isLoadingProduct && !isLoaded && handleSelect(product)
}
> >
<TableCell className="max-w-[300px] truncate"> <TableCell className="max-w-[300px] truncate">
{isLoadingProduct === product.pid && ( {isLoadingProduct === product.pid && (
<Loader2 className="h-3 w-3 animate-spin inline mr-2" /> <Loader2 className="h-3 w-3 animate-spin inline mr-2" />
)} )}
{isLoaded && (
<Check className="h-3 w-3 inline mr-2 text-green-600" />
)}
{product.title} {product.title}
</TableCell> </TableCell>
<TableCell>{product.sku}</TableCell> <TableCell>{product.sku}</TableCell>
@@ -132,11 +192,18 @@ export function ProductSearch({
product.regular_price} product.regular_price}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})}
</TableBody> </TableBody>
</Table> </Table>
</ScrollArea>
</div> </div>
{isTruncated && (
<p className="text-xs text-muted-foreground mt-2">
Showing top {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products.
</p>
)}
</CollapsibleContent>
</Collapsible>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -113,6 +113,11 @@ export default function ProductEditor() {
.finally(() => setIsLoadingSublines(false)); .finally(() => setIsLoadingSublines(false));
}, [lineLine]); }, [lineLine]);
const loadedPids = useMemo(
() => new Set(allProducts.map((p) => Number(p.pid))),
[allProducts]
);
const handleSearchSelect = useCallback((product: SearchProduct) => { const handleSearchSelect = useCallback((product: SearchProduct) => {
setAllProducts((prev) => { setAllProducts((prev) => {
if (prev.some((p) => p.pid === product.pid)) return prev; if (prev.some((p) => p.pid === product.pid)) return prev;
@@ -121,6 +126,39 @@ export default function ProductEditor() {
setPage(1); setPage(1);
}, []); }, []);
const handleNewSearch = useCallback(() => {
setAllProducts([]);
setPage(1);
}, []);
const handleLoadAllSearch = useCallback(async (pids: number[]) => {
const hadExisting = allProducts.length > 0;
setIsLoadingProducts(true);
try {
const res = await axios.get("/api/import/search-products", {
params: { pid: pids.join(",") },
});
const fetched = res.data as SearchProduct[];
setAllProducts((prev) => {
const existingPids = new Set(prev.map((p) => p.pid));
const newProducts = fetched.filter((p) => !existingPids.has(p.pid));
return [...prev, ...newProducts];
});
setPage(1);
if (fetched.length > 1) {
toast.success(
hadExisting
? `Loaded remaining ${fetched.length} products`
: "Loaded all products"
);
}
} catch {
toast.error("Failed to load products");
} finally {
setIsLoadingProducts(false);
}
}, []);
const handleRemoveProduct = useCallback((pid: number) => { const handleRemoveProduct = useCallback((pid: number) => {
setAllProducts((prev) => prev.filter((p) => p.pid !== pid)); setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
}, []); }, []);
@@ -195,6 +233,10 @@ export default function ProductEditor() {
} else if (tab === "hidden" && loadedTab !== "hidden") { } else if (tab === "hidden" && loadedTab !== "hidden") {
setLoadedTab("hidden"); setLoadedTab("hidden");
loadFeedProducts("hidden-new-products", "hidden"); loadFeedProducts("hidden-new-products", "hidden");
} else if (tab === "search" || tab === "by-line") {
abortRef.current?.abort();
setAllProducts([]);
setPage(1);
} }
}, [loadedTab, loadFeedProducts, loadLandingExtras]); }, [loadedTab, loadFeedProducts, loadLandingExtras]);
@@ -374,7 +416,12 @@ export default function ProductEditor() {
</TabsList> </TabsList>
<TabsContent value="search" className="mt-4"> <TabsContent value="search" className="mt-4">
<ProductSearch onSelect={handleSearchSelect} /> <ProductSearch
onSelect={handleSearchSelect}
onLoadAll={handleLoadAllSearch}
onNewSearch={handleNewSearch}
loadedPids={loadedPids}
/>
</TabsContent> </TabsContent>
<TabsContent value="new" className="mt-4"> <TabsContent value="new" className="mt-4">