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
let whereClause;
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 {
whereClause = `
WHERE (

View File

@@ -473,10 +473,14 @@ router.get('/search', async (req, res) => {
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
FROM products p
WHERE ${conditions.join(' AND ')}
WHERE ${whereClause}
ORDER BY
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
WHEN p.barcode ILIKE $${params.length + 1} THEN 1
@@ -485,9 +489,15 @@ router.get('/search', async (req, res) => {
END,
p.total_sold DESC NULLS LAST
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) {
console.error('Error searching products:', error);
res.status(500).json({ error: 'Search failed' });

View File

@@ -12,10 +12,16 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Search } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Check, ChevronDown, Loader2, Search } from "lucide-react";
import type { SearchProduct } from "./types";
const SEARCH_LIMIT = 50;
interface QuickSearchResult {
pid: number;
title: string;
@@ -29,31 +35,43 @@ interface QuickSearchResult {
export function ProductSearch({
onSelect,
onLoadAll,
onNewSearch,
loadedPids,
}: {
onSelect: (product: SearchProduct) => void;
onLoadAll: (pids: number[]) => void;
onNewSearch: () => void;
loadedPids: Set<number>;
}) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
const [resultsOpen, setResultsOpen] = useState(false);
const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return;
setIsSearching(true);
onNewSearch();
try {
const res = await axios.get("/api/products/search", {
params: { q: searchTerm },
});
setSearchResults(res.data);
setSearchResults(res.data.results);
setTotalCount(res.data.total);
setResultsOpen(true);
} catch {
toast.error("Search failed");
} finally {
setIsSearching(false);
}
}, [searchTerm]);
}, [searchTerm, onNewSearch]);
const handleSelect = useCallback(
async (product: QuickSearchResult) => {
if (loadedPids.has(Number(product.pid))) return;
setIsLoadingProduct(product.pid);
try {
const res = await axios.get("/api/import/search-products", {
@@ -62,7 +80,7 @@ export function ProductSearch({
const full = (res.data as SearchProduct[])[0];
if (full) {
onSelect(full);
setSearchResults([]);
setResultsOpen(false);
} else {
toast.error("Could not load full product details");
}
@@ -72,9 +90,23 @@ export function ProductSearch({
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 (
<Card>
<CardHeader>
@@ -98,29 +130,57 @@ export function ProductSearch({
</div>
{searchResults.length > 0 && (
<div className="mt-4 border rounded-md">
<ScrollArea className="max-h-80">
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-4">
<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>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Brand</TableHead>
<TableHead>Line</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="sticky top-0 bg-background">Name</TableHead>
<TableHead className="sticky top-0 bg-background">SKU</TableHead>
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
<TableHead className="sticky top-0 bg-background">Line</TableHead>
<TableHead className="sticky top-0 bg-background text-right">
Price
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchResults.map((product) => (
{searchResults.map((product) => {
const isLoaded = loadedPids.has(Number(product.pid));
return (
<TableRow
key={product.pid}
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
onClick={() => !isLoadingProduct && handleSelect(product)}
className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
onClick={() =>
!isLoadingProduct && !isLoaded && handleSelect(product)
}
>
<TableCell className="max-w-[300px] truncate">
{isLoadingProduct === product.pid && (
<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}
</TableCell>
<TableCell>{product.sku}</TableCell>
@@ -132,11 +192,18 @@ export function ProductSearch({
product.regular_price}
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</ScrollArea>
</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>
</Card>

View File

@@ -113,6 +113,11 @@ export default function ProductEditor() {
.finally(() => setIsLoadingSublines(false));
}, [lineLine]);
const loadedPids = useMemo(
() => new Set(allProducts.map((p) => Number(p.pid))),
[allProducts]
);
const handleSearchSelect = useCallback((product: SearchProduct) => {
setAllProducts((prev) => {
if (prev.some((p) => p.pid === product.pid)) return prev;
@@ -121,6 +126,39 @@ export default function ProductEditor() {
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) => {
setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
}, []);
@@ -195,6 +233,10 @@ export default function ProductEditor() {
} else if (tab === "hidden" && loadedTab !== "hidden") {
setLoadedTab("hidden");
loadFeedProducts("hidden-new-products", "hidden");
} else if (tab === "search" || tab === "by-line") {
abortRef.current?.abort();
setAllProducts([]);
setPage(1);
}
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
@@ -374,7 +416,12 @@ export default function ProductEditor() {
</TabsList>
<TabsContent value="search" className="mt-4">
<ProductSearch onSelect={handleSearchSelect} />
<ProductSearch
onSelect={handleSearchSelect}
onLoadAll={handleLoadAllSearch}
onNewSearch={handleNewSearch}
loadedPids={loadedPids}
/>
</TabsContent>
<TabsContent value="new" className="mt-4">