Add loading products from PT query to editor, product editor search enhancements
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user