new product admin page to flag bad products
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user