Add loading products from PT query to editor, product editor search enhancements

This commit is contained in:
2026-03-18 15:29:00 -04:00
parent 1b836567cd
commit f8b81d2111
4 changed files with 1090 additions and 113 deletions
@@ -3,7 +3,6 @@ import axios from "axios";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -20,7 +19,7 @@ import {
import { Check, ChevronDown, Loader2, Search } from "lucide-react";
import type { SearchProduct } from "./types";
const SEARCH_LIMIT = 50;
const SEARCH_LIMIT = 100;
interface QuickSearchResult {
pid: number;
@@ -50,6 +49,7 @@ export function ProductSearch({
const [isSearching, setIsSearching] = useState(false);
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
const [resultsOpen, setResultsOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return;
@@ -108,104 +108,106 @@ export function ProductSearch({
const isTruncated = totalCount > SEARCH_LIMIT;
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Search Products</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Search by name, SKU, UPC, brand..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button onClick={handleSearch} disabled={isSearching}>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
<div>
<div className="flex gap-3">
<Input
placeholder="Search products…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<Button onClick={handleSearch} disabled={isSearching}>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
{(isFocused || searchResults.length === 0) && (
<p className="text-xs text-muted-foreground mt-1 ml-3">
Search by name, item number, UPC, company, supplier, supplier id, notions #, line, subline, artist
</p>
)}
{searchResults.length > 0 && (
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-3">
<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>
)}
</Button>
</div>
</div>
{searchResults.length > 0 && (
<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>
)}
<CollapsibleContent>
<div className="border rounded-md mt-2 max-h-80 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="sticky top-0 bg-background">Name</TableHead>
<TableHead className="sticky top-0 bg-background">Item Number</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) => {
const isLoaded = loadedPids.has(Number(product.pid));
return (
<TableRow
key={product.pid}
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>
<TableCell>{product.brand}</TableCell>
<TableCell>{product.line}</TableCell>
<TableCell className="text-right">
$
{Number(product.regular_price)?.toFixed(2) ??
product.regular_price}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<CollapsibleContent>
<div className="border rounded-md mt-2 max-h-80 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<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) => {
const isLoaded = loadedPids.has(Number(product.pid));
return (
<TableRow
key={product.pid}
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>
<TableCell>{product.brand}</TableCell>
<TableCell>{product.line}</TableCell>
<TableCell className="text-right">
$
{Number(product.regular_price)?.toFixed(2) ??
product.regular_price}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</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>
{isTruncated && (
<p className="text-xs text-muted-foreground mt-2">
Showing only the first {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products.
</p>
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
+216 -9
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
@@ -20,10 +21,117 @@ import { ProductSearch } from "@/components/product-editor/ProductSearch";
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
import { ExternalLink } from "lucide-react";
const PER_PAGE = 20;
const PROD_IMG_HOST = "https://sbing.com";
interface FilterSummaryItem {
type: string;
op: string;
v1: string | null;
v2: string | null;
v1Label: string | null;
v2Label: string | null;
}
const OP_SYMBOLS: Record<string, string> = {
equals: "=",
notequals: "≠",
greater: ">",
greater_equals: "≥",
less: "<",
less_equals: "≤",
contains: "contains",
notcontains: "excludes",
begins: "starts with",
};
function formatFilterBadges(filters: FilterSummaryItem[]): { key: string; text: string }[] {
// Group same type+op rows so "line=5 OR line=7" becomes "Line = Lawn Fawn, Carta Bella"
const groups = new Map<string, { type: string; op: string; v1s: string[]; v2: string | null }>();
for (const f of filters) {
const key = `${f.type}:${f.op}`;
const display = f.v1Label ?? f.v1;
if (groups.has(key)) {
if (display) groups.get(key)!.v1s.push(display);
} else {
groups.set(key, { type: f.type, op: f.op, v1s: display ? [display] : [], v2: f.v2Label ?? f.v2 });
}
}
return Array.from(groups.entries()).map(([key, g]) => {
const label = FILTER_LABELS[g.type] ?? g.type.replace(/_/g, " ");
let text: string;
if (["true", "true1"].includes(g.op)) {
text = label;
} else if (g.op === "false") {
text = `not ${label}`;
} else if (g.op === "isnull") {
text = `${label} is empty`;
} else if (g.op === "between" && g.v2) {
text = `${label}: ${g.v1s[0] ?? ""}${g.v2}`;
} else {
const sym = OP_SYMBOLS[g.op] ?? g.op;
text = g.v1s.length ? `${label} ${sym} ${g.v1s.join(", ")}` : label;
}
return { key, text };
});
}
const FILTER_LABELS: Record<string, string> = {
company: "company",
line: "line",
subline: "subline",
no_company: "no company",
no_line: "no line",
no_subline: "no subline",
artist: "artist",
price: "price",
default_price: "default price",
price_for_sort: "price for sort",
salepercent_for_sort: "sale % for sort",
"salepercent_for_sort__clearance": "sale % for sort (clearance)",
weight: "weight",
weight_price_ratio: "weight/price ratio",
price_weight_ratio: "price/weight ratio",
length: "length",
width: "width",
height: "height",
no_dim: "no dimensions",
size_cat: "size category",
dimension: "dimension",
yarn_weight: "yarn weight",
material: "material",
hide: "hidden",
hide_in_shop: "hidden in shop",
discontinued: "discontinued",
force_flag: "force flag",
exclusive: "exclusive",
lock_quantity: "lock qty",
show_notify: "show notify",
downloadable: "downloadable",
usa_only: "usa only",
not_clearance: "not clearance",
stat_stop: "stats stopped",
notnew: "not new",
not_backinstock: "not back-in-stock",
reorder: "reorder",
score: "score",
sold_view_score: "sold/view score",
visibility_score: "visibility score",
health_score: "health score",
tax_code: "tax code",
investor: "investor",
category: "category",
theme: "theme",
pid: "product id",
basket: "basket",
vendor: "vendor",
vendor_reference: "supplier id",
notions_reference: "notions id",
itemnumber: "item number",
};
/** Strip all HTML except <b>, </b>, and <br> tags */
function sanitizeHtml(html: string): string {
return html.replace(/<\/?(?!b>|br\s*\/?>)[^>]*>/gi, "");
@@ -40,15 +148,14 @@ export default function ProductEditor() {
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
const [page, _setPage] = useState(1);
const topRef = useRef<HTMLDivElement>(null);
const setPage = useCallback((v: number | ((p: number) => number)) => {
_setPage(v);
setTimeout(() => topRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
}, []);
const [page, setPage] = useState(1);
const [activeTab, setActiveTab] = useState("new");
const [loadedTab, setLoadedTab] = useState<string | null>(null);
// Query picker state
const [queryId, setQueryId] = useState<string>("");
const [queryStatus, setQueryStatus] = useState<{ id: string; name: string; count: number; filters: FilterSummaryItem[]; unsupported: string[] } | null>(null);
// Line picker state
const [lineCompany, setLineCompany] = useState<string>("");
const [lineLine, setLineLine] = useState<string>("");
@@ -222,6 +329,8 @@ export default function ProductEditor() {
// Auto-load when switching tabs
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab);
setQueryStatus(null);
setQueryId("");
if (tab === "new" && loadedTab !== "new") {
setLoadedTab("new");
loadFeedProducts("new-products", "new");
@@ -233,7 +342,7 @@ export default function ProductEditor() {
} else if (tab === "hidden" && loadedTab !== "hidden") {
setLoadedTab("hidden");
loadFeedProducts("hidden-new-products", "hidden");
} else if (tab === "search" || tab === "by-line") {
} else if (tab === "search" || tab === "by-line" || tab === "by-query") {
abortRef.current?.abort();
setAllProducts([]);
setPage(1);
@@ -261,6 +370,40 @@ export default function ProductEditor() {
}
}, [lineCompany, lineLine, lineSubline]);
const loadQueryProducts = useCallback(async () => {
const qid = queryId.trim();
if (!qid || isNaN(Number(qid))) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setAllProducts([]);
setQueryStatus(null);
setIsLoadingProducts(true);
try {
const res = await axios.get("/api/import/query-products", {
params: { query_id: qid },
signal: controller.signal,
});
const { results, filters, unsupported } = res.data;
setAllProducts(results);
setPage(1);
setQueryStatus({
id: qid,
name: res.headers["x-query-name"] || "",
count: results.length,
filters: filters ?? [],
unsupported: unsupported ?? [],
});
if (unsupported?.length) {
toast.warning(`Query #${qid}: ${unsupported.length} unsupported filter(s) removed — results may be broader than expected`);
}
} catch (e) {
if (!axios.isCancel(e)) toast.error("Failed to load query products");
} finally {
setIsLoadingProducts(false);
}
}, [queryId]);
const renderLandingExtras = (tabKey: string) => {
const extras = landingExtras[tabKey];
if (!extras || extras.length === 0) return null;
@@ -412,6 +555,7 @@ export default function ProductEditor() {
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
<TabsTrigger value="by-line">By Line</TabsTrigger>
<TabsTrigger value="by-query">By Query</TabsTrigger>
<TabsTrigger value="search">Search</TabsTrigger>
</TabsList>
@@ -424,6 +568,64 @@ export default function ProductEditor() {
/>
</TabsContent>
<TabsContent value="by-query" className="mt-4">
<div className="flex items-center gap-3">
<Input
placeholder="Query ID..."
value={queryId}
onChange={(e) => setQueryId(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") loadQueryProducts(); }}
className="w-52"
/>
<Button onClick={loadQueryProducts} disabled={!queryId.trim() || isNaN(Number(queryId)) || isLoadingProducts}>
{isLoadingProducts && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Load
</Button>
{queryStatus && (
<Button variant="outline" size="icon" onClick={loadQueryProducts} disabled={isLoadingProducts} title="Refresh query results">
<RefreshCw className="h-4 w-4" />
</Button>
)}
</div>
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
<Loader2 className="h-4 w-4 animate-spin" />
Loading products...
</div>
)}
{queryStatus && !isLoadingProducts && (
<div className="mt-3 text-sm text-muted-foreground space-y-1.5">
<div>
Showing {queryStatus.count} product{queryStatus.count !== 1 ? "s" : ""} from query {queryStatus.id}
{queryStatus.name ? `${queryStatus.name}` : ""}.{" "}
<a
href={`https://backend.acherryontop.com/product_tool/${queryStatus.id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 underline hover:text-foreground"
>
Open in Product Tool <ExternalLink className="h-3 w-3" />
</a>
</div>
{queryStatus.filters.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
<span className="text-xs">Filters:</span>
{formatFilterBadges(queryStatus.filters).map(({ key, text }) => (
<span key={key} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium">
{text}
</span>
))}
</div>
)}
{queryStatus.unsupported.length > 0 && (
<div className="text-amber-600 dark:text-amber-400">
{queryStatus.unsupported.length} filter type{queryStatus.unsupported.length !== 1 ? "s" : ""} not supported ({queryStatus.unsupported.join(", ")}). Results may be broader than expected.
</div>
)}
</div>
)}
</TabsContent>
<TabsContent value="new" className="mt-4">
{isLoadingExtras && !landingExtras["new"] && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
@@ -504,10 +706,15 @@ export default function ProductEditor() {
Load
</Button>
</div>
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
<Loader2 className="h-4 w-4 animate-spin" />
Loading line products...
</div>
)}
</TabsContent>
</Tabs>
<div ref={topRef} />
{renderPagination()}
{products.length > 0 && fieldOptions && (