new product admin page to flag bad products

This commit is contained in:
2025-12-30 09:04:56 -05:00
parent cc386d14f4
commit ee91fa1353
4 changed files with 703 additions and 446 deletions
+74 -27
View File
@@ -20,12 +20,28 @@ import PartsGrid from "@/components/parts/PartsGrid";
import Pagination from "@/components/parts/Pagination";
import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
import type { CategoryId } from "@/types/builderSlots";
import type { Category } from "@/types/builderSlots";
import {
PART_ROLE_TO_CATEGORY,
normalizePartRole,
} from "@/lib/catalogMappings";
type PageResponse<T> = {
content: T[];
totalElements: number;
totalPages: number;
number: number; // 0-based
size: number;
};
function unwrapContent<T>(json: any): { items: T[]; page?: PageResponse<T> } {
if (!json) return { items: [] };
if (Array.isArray(json)) return { items: json };
if (Array.isArray(json.content))
return { items: json.content, page: json as PageResponse<T> };
return { items: [] };
}
type ViewMode = "card" | "list";
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
@@ -116,36 +132,62 @@ export default function PartsBrowseClient(props: {
max: null,
});
const [inStockOnly, setInStockOnly] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [currentPage, setCurrentPage] = useState(1); // UI 1-based
const [serverTotalPages, setServerTotalPages] = useState(1);
const [serverTotalElements, setServerTotalElements] = useState(0);
// ----------------------------
// Fetch parts
// ----------------------------
useEffect(() => {
if (!partRole) return;
const controller = new AbortController();
async function fetchParts() {
try {
setLoading(true);
setError(null);
const search = new URLSearchParams();
search.set("platform", effectivePlatform);
search.append("partRoles", partRole);
const res = await fetch(
`${API_BASE_URL}/api/v1/products?${search.toString()}`,
{ signal: controller.signal }
);
// server paging (0-based)
search.set("page", String(currentPage - 1));
search.set("size", String(PAGE_SIZE));
// optional server search + sort (backend can ignore for now)
if (searchQuery.trim()) search.set("q", searchQuery.trim());
if (brandFilter.length) brandFilter.forEach((b) => search.append("brand", b));
// sort mapping
switch (sortBy) {
case "price-asc":
search.set("sort", "price,asc");
break;
case "price-desc":
search.set("sort", "price,desc");
break;
case "brand-asc":
search.set("sort", "brand,asc");
break;
case "relevance":
default:
search.set("sort", "updatedAt,desc");
break;
}
const res = await fetch(`${API_BASE_URL}/api/v1/products?${search.toString()}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
const data: GunbuilderProductFromApi[] = await res.json();
const json = await res.json();
const { items, page } = unwrapContent<GunbuilderProductFromApi>(json);
setParts(
data.map((p) => ({
items.map((p) => ({
id: normalizeId(p.id),
name: p.name,
brand: p.brand,
@@ -157,6 +199,15 @@ export default function PartsBrowseClient(props: {
inStock: p.inStock ?? true,
}))
);
if (page) {
setServerTotalPages(page.totalPages ?? 1);
setServerTotalElements(page.totalElements ?? items.length);
} else {
// backward compat
setServerTotalPages(1);
setServerTotalElements(items.length);
}
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
@@ -164,11 +215,10 @@ export default function PartsBrowseClient(props: {
setLoading(false);
}
}
fetchParts();
return () => controller.abort();
}, [partRole, effectivePlatform]);
}, [partRole, effectivePlatform, currentPage, searchQuery, sortBy, brandFilter]);
// Reset pagination on filters
useEffect(() => {
setCurrentPage(1);
@@ -265,15 +315,12 @@ export default function PartsBrowseClient(props: {
return result;
}, [parts, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
const totalPages = Math.max(1, Math.ceil(filteredParts.length / PAGE_SIZE));
const paginatedParts = filteredParts.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
);
const totalPages = Math.max(1, serverTotalPages);
const paginatedParts = filteredParts; // filteredParts should become just parts now (see below)
const visibleRange = {
start: filteredParts.length ? (currentPage - 1) * PAGE_SIZE + 1 : 0,
end: Math.min(currentPage * PAGE_SIZE, filteredParts.length),
start: serverTotalElements ? (currentPage - 1) * PAGE_SIZE + 1 : 0,
end: Math.min(currentPage * PAGE_SIZE, serverTotalElements),
};
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`;
@@ -284,7 +331,7 @@ export default function PartsBrowseClient(props: {
// ----------------------------
const handleAddToBuild = (p: UiPart) => {
const normalizedRole = normalizePartRole(partRole);
const categoryId: CategoryId | null =
const categoryId: Category | null =
PART_ROLE_TO_CATEGORY[partRole] ??
PART_ROLE_TO_CATEGORY[normalizedRole] ??
null;