new product admin page to flag bad products
This commit is contained in:
@@ -92,10 +92,10 @@ const CATEGORY_GROUPS: {
|
||||
categoryIds: [
|
||||
"lower-receiver",
|
||||
"complete-lower",
|
||||
"lower-parts-kit", // ✅ was lower-parts
|
||||
"lower-parts-kit",
|
||||
"trigger",
|
||||
"grip",
|
||||
"safety-selector", // ✅ was safety
|
||||
"safety-selector",
|
||||
"buffer",
|
||||
"stock",
|
||||
],
|
||||
@@ -192,6 +192,21 @@ export default function GunbuilderPage() {
|
||||
return {};
|
||||
});
|
||||
|
||||
type PageResponse<T> = {
|
||||
content: T[];
|
||||
totalElements?: number;
|
||||
totalPages?: number;
|
||||
number?: number;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
function unwrapPage<T>(json: any): T[] {
|
||||
if (!json) return [];
|
||||
if (Array.isArray(json)) return json;
|
||||
if (Array.isArray(json.content)) return json.content;
|
||||
return [];
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Toast notifications (save success / errors)
|
||||
// -----------------------------
|
||||
@@ -374,10 +389,11 @@ export default function GunbuilderPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const size = 2000; // temporary "big bucket" until we optimize
|
||||
const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
|
||||
platform
|
||||
)}`;
|
||||
const universalUrl = `${API_BASE_URL}/api/v1/products`;
|
||||
)}&page=0&size=${size}`;
|
||||
const universalUrl = `${API_BASE_URL}/api/v1/products?page=0&size=${size}`;
|
||||
|
||||
const [scopedRes, universalRes] = await Promise.all([
|
||||
fetch(scopedUrl, { signal: controller.signal }),
|
||||
@@ -391,13 +407,15 @@ export default function GunbuilderPage() {
|
||||
throw new Error(`Failed to load products (${status})`);
|
||||
}
|
||||
|
||||
const scopedData: GunbuilderProductFromApi[] = await scopedRes.json();
|
||||
const scopedJson = await scopedRes.json();
|
||||
const scopedData: GunbuilderProductFromApi[] =
|
||||
unwrapPage<GunbuilderProductFromApi>(scopedJson);
|
||||
|
||||
let universalData: GunbuilderProductFromApi[] = [];
|
||||
if (universalRes && universalRes.ok) {
|
||||
universalData = await universalRes.json();
|
||||
const universalJson = await universalRes.json();
|
||||
universalData = unwrapPage<GunbuilderProductFromApi>(universalJson);
|
||||
}
|
||||
|
||||
const normalize = (data: GunbuilderProductFromApi[]): Part[] =>
|
||||
data
|
||||
.map((p): Part | null => {
|
||||
@@ -649,31 +667,30 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// -----------------------------
|
||||
// Selection handler (with hint warnings)
|
||||
// -----------------------------
|
||||
const handleSelectPart = useCallback(
|
||||
(
|
||||
categoryId: BuilderSlotKey,
|
||||
partId: string,
|
||||
_opts?: { confirm?: boolean }
|
||||
) => {
|
||||
const warn = (msg: string) => {
|
||||
setShareStatus(msg);
|
||||
window.setTimeout(() => setShareStatus(null), 4000);
|
||||
};
|
||||
// Selection handler (with hint warnings)
|
||||
// -----------------------------
|
||||
const handleSelectPart = useCallback(
|
||||
(
|
||||
categoryId: BuilderSlotKey,
|
||||
partId: string,
|
||||
_opts?: { confirm?: boolean }
|
||||
) => {
|
||||
const warn = (msg: string) => {
|
||||
setShareStatus(msg);
|
||||
window.setTimeout(() => setShareStatus(null), 4000);
|
||||
};
|
||||
|
||||
const hints = getSelectionHints(categoryId, build);
|
||||
if (hints.length > 0) warn(hints[0]);
|
||||
const hints = getSelectionHints(categoryId, build);
|
||||
if (hints.length > 0) warn(hints[0]);
|
||||
|
||||
setBuild((prev) => ({
|
||||
...prev,
|
||||
[categoryId]: partId,
|
||||
}));
|
||||
},
|
||||
[build]
|
||||
);
|
||||
setBuild((prev) => ({
|
||||
...prev,
|
||||
[categoryId]: partId,
|
||||
}));
|
||||
},
|
||||
[build]
|
||||
);
|
||||
// -----------------------------
|
||||
// Handle URL query parameters:
|
||||
// - ?select=categoryId:partId
|
||||
|
||||
+581
-388
@@ -2,410 +2,603 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type ProductSummary = {
|
||||
type ProductVisibility = "PUBLIC" | "HIDDEN";
|
||||
type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
|
||||
|
||||
type AdminProductRow = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
buyUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
slug: string;
|
||||
platform: string | null;
|
||||
partRole: string | null;
|
||||
brandName: string | null;
|
||||
importStatus: string | null;
|
||||
|
||||
visibility: ProductVisibility;
|
||||
status: ProductStatus;
|
||||
builderEligible: boolean;
|
||||
adminLocked: boolean;
|
||||
|
||||
adminNote: string | null;
|
||||
mainImageUrl: string | null;
|
||||
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
type PageResponse<T> = {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
type PlatformOption = {
|
||||
key: string; // e.g. AR15, AR9
|
||||
label: string; // e.g. "Armalite Rifle model 15"
|
||||
};
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie.match(
|
||||
new RegExp(
|
||||
"(^| )" + name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") + "=([^;]+)"
|
||||
)
|
||||
);
|
||||
return match ? decodeURIComponent(match[2]) : null;
|
||||
}
|
||||
|
||||
// Backend currently filters products by canonical platform strings like "AR-15".
|
||||
// DB platform keys are like "AR15" / "AR9". Translate when calling /api/v1/products.
|
||||
const platformKeyToApiPlatform = (key: string) => {
|
||||
const k = (key ?? "").toUpperCase().trim();
|
||||
if (!k) return "AR-15";
|
||||
if (k === "AR15" || k === "AR-15") return "AR-15";
|
||||
if (k === "AR10" || k === "AR-10") return "AR-10";
|
||||
if (k === "AR9" || k === "AR-9") return "AR-9";
|
||||
// Fallback: if it already contains a dash, assume it's canonical.
|
||||
if (k.includes("-")) return k;
|
||||
// Otherwise, best-effort: insert a dash after AR.
|
||||
if (k.startsWith("AR") && k.length > 2) return `AR-${k.slice(2)}`;
|
||||
return k;
|
||||
};
|
||||
function getToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const FALLBACK_PLATFORMS: PlatformOption[] = [
|
||||
{ key: "AR15", label: "AR-15" },
|
||||
{ key: "AR10", label: "AR-10" },
|
||||
{ key: "AR9", label: "AR-9" },
|
||||
];
|
||||
// 1) Prefer localStorage (your canonical token store)
|
||||
const ls = localStorage.getItem("ballistic_auth_token");
|
||||
if (ls && ls.trim().length > 0) return ls;
|
||||
|
||||
// 2) Fallback to cookie (only works if NOT HttpOnly)
|
||||
const ck = getCookie("bb_access_token");
|
||||
if (ck && ck.trim().length > 0) return ck;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRole(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const lsUser = localStorage.getItem("ballistic_auth_user");
|
||||
if (lsUser) {
|
||||
try {
|
||||
const parsed = JSON.parse(lsUser);
|
||||
// adjust if your user object uses a different key
|
||||
return parsed?.role ?? parsed?.roles?.[0] ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const ckRole = getCookie("bb_role");
|
||||
return ckRole ?? null;
|
||||
}
|
||||
|
||||
function qs(params: Record<string, any>) {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || v === "") return;
|
||||
sp.set(k, String(v));
|
||||
});
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
async function fetchAdminProducts(params: Record<string, any>) {
|
||||
const token = getToken();
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/products?${qs(params)}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`Failed to load admin products (${res.status}): ${txt}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as PageResponse<AdminProductRow>;
|
||||
}
|
||||
|
||||
async function bulkUpdate(
|
||||
productIds: number[],
|
||||
set: Partial<{
|
||||
visibility: ProductVisibility;
|
||||
status: ProductStatus;
|
||||
builderEligible: boolean;
|
||||
adminLocked: boolean;
|
||||
adminNote: string;
|
||||
}>
|
||||
) {
|
||||
const token = getToken();
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/products/bulk`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ productIds, ...set }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`Bulk update failed (${res.status}): ${txt}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as { updatedCount: number };
|
||||
}
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [platforms, setPlatforms] = useState<PlatformOption[]>(FALLBACK_PLATFORMS);
|
||||
const [platform, setPlatform] = useState<string>("AR15");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [products, setProducts] = useState<ProductSummary[]>([]);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(50);
|
||||
|
||||
// Load available platforms (dynamic if backend supports it; otherwise fallback)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadPlatforms() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/platforms`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = (await res.json()) as unknown;
|
||||
|
||||
// Expected shape:
|
||||
// [{ id, key, label, is_active, ... }]
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
|
||||
const cleaned: PlatformOption[] = items
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const obj = item as Record<string, unknown>;
|
||||
|
||||
const key = typeof obj.key === "string" ? obj.key.trim() : "";
|
||||
const label = typeof obj.label === "string" ? obj.label.trim() : "";
|
||||
|
||||
// Some payloads might use isActive; yours currently uses is_active
|
||||
const activeRaw = obj.is_active ?? obj.isActive;
|
||||
const isActive =
|
||||
typeof activeRaw === "boolean"
|
||||
? activeRaw
|
||||
: typeof activeRaw === "number"
|
||||
? activeRaw === 1
|
||||
: true;
|
||||
|
||||
if (!key || !label || !isActive) return null;
|
||||
return { key, label } as PlatformOption;
|
||||
})
|
||||
.filter((v): v is PlatformOption => v !== null);
|
||||
|
||||
if (cleaned.length === 0) return;
|
||||
|
||||
// Keep them stable and unique by key
|
||||
const byKey = new Map<string, PlatformOption>();
|
||||
for (const p of cleaned) byKey.set(p.key, p);
|
||||
const unique = Array.from(byKey.values());
|
||||
|
||||
setPlatforms(unique);
|
||||
setPlatform((prev) => (unique.some((p) => p.key === prev) ? prev : unique[0].key));
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
// ignore (fallback list stays)
|
||||
}
|
||||
}
|
||||
|
||||
loadPlatforms();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Load products for selected platform
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadProducts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const apiPlatform = platformKeyToApiPlatform(platform);
|
||||
|
||||
// NOTE: backend endpoint expects `platform`, not `platformKey`.
|
||||
const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
|
||||
apiPlatform
|
||||
)}`;
|
||||
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load products (${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as ProductSummary[];
|
||||
setProducts(Array.isArray(data) ? data : []);
|
||||
setPage(1);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
setError(err?.message ?? "Failed to load products");
|
||||
setProducts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadProducts();
|
||||
return () => controller.abort();
|
||||
}, [platform]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return products;
|
||||
|
||||
return products.filter((p) => {
|
||||
const name = (p.name ?? "").toLowerCase();
|
||||
const brand = (p.brand ?? "").toLowerCase();
|
||||
const role = (p.partRole ?? "").toLowerCase();
|
||||
return name.includes(q) || brand.includes(q) || role.includes(q);
|
||||
});
|
||||
}, [products, query]);
|
||||
|
||||
// Reset to page 1 when the search query changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query, platform, pageSize]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = filtered.length;
|
||||
return total === 0 ? 1 : Math.ceil(total / pageSize);
|
||||
}, [filtered.length, pageSize]);
|
||||
|
||||
const clampedPage = useMemo(() => {
|
||||
if (page < 1) return 1;
|
||||
if (page > totalPages) return totalPages;
|
||||
return page;
|
||||
}, [page, totalPages]);
|
||||
|
||||
const pageStart = useMemo(() => (clampedPage - 1) * pageSize, [clampedPage, pageSize]);
|
||||
const pageEndExclusive = useMemo(
|
||||
() => Math.min(pageStart + pageSize, filtered.length),
|
||||
[pageStart, pageSize, filtered.length]
|
||||
// filters
|
||||
const [q, setQ] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [partRole, setPartRole] = useState("");
|
||||
const [visibility, setVisibility] = useState<ProductVisibility | "">("");
|
||||
const [status, setStatus] = useState<ProductStatus | "">("");
|
||||
const [builderEligible, setBuilderEligible] = useState<"" | "true" | "false">(
|
||||
""
|
||||
);
|
||||
const [adminLocked, setAdminLocked] = useState<"" | "true" | "false">("");
|
||||
|
||||
const paginated = useMemo(() => {
|
||||
return filtered.slice(pageStart, pageEndExclusive);
|
||||
}, [filtered, pageStart, pageEndExclusive]);
|
||||
// paging
|
||||
const [page, setPage] = useState(0);
|
||||
const [size, setSize] = useState(50);
|
||||
|
||||
// data
|
||||
const [data, setData] = useState<PageResponse<AdminProductRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// selection
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
return {
|
||||
q,
|
||||
platform,
|
||||
partRole,
|
||||
visibility,
|
||||
status,
|
||||
builderEligible: builderEligible === "" ? null : builderEligible,
|
||||
adminLocked: adminLocked === "" ? null : adminLocked,
|
||||
page,
|
||||
size,
|
||||
sort: "updatedAt,desc",
|
||||
};
|
||||
}, [
|
||||
q,
|
||||
platform,
|
||||
partRole,
|
||||
visibility,
|
||||
status,
|
||||
builderEligible,
|
||||
adminLocked,
|
||||
page,
|
||||
size,
|
||||
]);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetchAdminProducts(queryParams);
|
||||
setData(res);
|
||||
// clear selection on refresh so you don’t accidentally nuke new stuff
|
||||
setSelected(new Set());
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? "Failed to load.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryParams.page, queryParams.size]); // only auto-refresh on page/size
|
||||
|
||||
const content = data?.content ?? [];
|
||||
|
||||
const allOnPageSelected =
|
||||
content.length > 0 && content.every((r) => selected.has(r.id));
|
||||
|
||||
const toggleAllOnPage = () => {
|
||||
const next = new Set(selected);
|
||||
if (allOnPageSelected) {
|
||||
content.forEach((r) => next.delete(r.id));
|
||||
} else {
|
||||
content.forEach((r) => next.add(r.id));
|
||||
}
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const selectedIds = Array.from(selected);
|
||||
|
||||
const runBulk = async (set: Parameters<typeof bulkUpdate>[1]) => {
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
// no confirmations per your “keep moving” vibe, but you can add one later
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
|
||||
try {
|
||||
const res = await bulkUpdate(selectedIds, set);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Bulk updated:", res.updatedCount);
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? "Bulk update failed.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<header className="mb-6">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Admin
|
||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Admin · Products</h1>
|
||||
<p className="text-sm opacity-70">
|
||||
Filter, select, and apply bulk actions. This is where bad imports
|
||||
come to die.
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Products <span className="text-amber-300">Catalog</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||
Read-only view of products coming from the Ballistic backend. Filter by
|
||||
platform, search, and spot-check pricing + buy links.
|
||||
</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||
Platform
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{platforms.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="q" className="text-xs text-zinc-500">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id="q"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="brand, name, part role…"
|
||||
className="w-64 max-w-full rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||
Page size
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{[25, 50, 100, 200].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500">
|
||||
{loading ? (
|
||||
<span>Loading…</span>
|
||||
) : error ? (
|
||||
<span className="text-red-400">{error}</span>
|
||||
) : (
|
||||
<span>
|
||||
Showing{" "}
|
||||
<span className="font-medium text-zinc-200">
|
||||
{filtered.length === 0 ? 0 : pageStart + 1}-{pageEndExclusive}
|
||||
</span>
|
||||
{" "}of{" "}
|
||||
<span className="font-medium text-zinc-200">{filtered.length}</span>
|
||||
{" "}{filtered.length === 1 ? "product" : "products"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-[0.7rem] text-zinc-500">
|
||||
Page <span className="font-semibold text-zinc-200">{clampedPage}</span> of{" "}
|
||||
<span className="font-semibold text-zinc-200">{totalPages}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={clampedPage === 1}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={clampedPage === totalPages}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
|
||||
<table className="min-w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60">
|
||||
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Product</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Brand</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Platform</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Part Role</th>
|
||||
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Price</th>
|
||||
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
Loading products…
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-sm text-red-400" colSpan={6}>
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
) : paginated.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
No products found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginated.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="font-medium text-zinc-100 line-clamp-2">{p.name}</div>
|
||||
<div className="mt-0.5 text-[0.7rem] text-zinc-600">ID: {p.id}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-zinc-300">{p.brand}</td>
|
||||
<td className="px-3 py-2 align-top text-zinc-300">
|
||||
{p.platform ?? platformKeyToApiPlatform(platform)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-zinc-300">{p.partRole}</td>
|
||||
<td className="px-3 py-2 align-top text-right text-amber-300">
|
||||
{p.price == null ? "—" : `$${Number(p.price).toFixed(2)}`}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-right">
|
||||
{p.buyUrl ? (
|
||||
<a
|
||||
href={p.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md bg-amber-400 px-2.5 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-zinc-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={clampedPage === 1}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-zinc-500">
|
||||
Page{" "}
|
||||
<span className="font-semibold text-zinc-200">
|
||||
{clampedPage}
|
||||
</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-semibold text-zinc-200">
|
||||
{totalPages}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={clampedPage === totalPages}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||
aria-label="Next page"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-[0.7rem] text-zinc-500">
|
||||
Backend: <span className="font-mono text-zinc-400">{API_BASE_URL}</span> · Endpoint: <span className="font-mono text-zinc-400">/api/v1/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span>
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
onClick={() => refresh()}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-6">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search (name, slug, mpn, upc)…"
|
||||
className="md:col-span-2 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
<input
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
placeholder="Platform (AR-15)…"
|
||||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
<input
|
||||
value={partRole}
|
||||
onChange={(e) => setPartRole(e.target.value)}
|
||||
placeholder="Part role (barrel)…"
|
||||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={visibility}
|
||||
onChange={(e) => setVisibility(e.target.value as any)}
|
||||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Visibility (any)</option>
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="HIDDEN">HIDDEN</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as any)}
|
||||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Status (any)</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="DISABLED">DISABLED</option>
|
||||
<option value="ARCHIVED">ARCHIVED</option>
|
||||
</select>
|
||||
|
||||
<div className="md:col-span-6 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={builderEligible}
|
||||
onChange={(e) => setBuilderEligible(e.target.value as any)}
|
||||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Builder eligible (any)</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={adminLocked}
|
||||
onChange={(e) => setAdminLocked(e.target.value as any)}
|
||||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Admin locked (any)</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setPage(0);
|
||||
refresh();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
|
||||
onClick={() => {
|
||||
setQ("");
|
||||
setPlatform("");
|
||||
setPartRole("");
|
||||
setVisibility("");
|
||||
setStatus("");
|
||||
setBuilderEligible("");
|
||||
setAdminLocked("");
|
||||
setPage(0);
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm opacity-70">
|
||||
Selected:{" "}
|
||||
<span className="font-semibold opacity-100">
|
||||
{selectedIds.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || selectedIds.length === 0}
|
||||
onClick={() =>
|
||||
runBulk({
|
||||
visibility: "HIDDEN",
|
||||
builderEligible: false,
|
||||
adminLocked: true,
|
||||
adminNote: "Hidden + removed from builder (bulk)",
|
||||
})
|
||||
}
|
||||
>
|
||||
Hide + Remove from Builder + Lock
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || selectedIds.length === 0}
|
||||
onClick={() => runBulk({ builderEligible: false })}
|
||||
>
|
||||
Remove from Builder
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || selectedIds.length === 0}
|
||||
onClick={() => runBulk({ visibility: "HIDDEN" })}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || selectedIds.length === 0}
|
||||
onClick={() => runBulk({ status: "DISABLED" })}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || selectedIds.length === 0}
|
||||
onClick={() => runBulk({ adminLocked: true })}
|
||||
>
|
||||
Lock
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || selectedIds.length === 0}
|
||||
onClick={() => runBulk({ adminLocked: false })}
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{err && (
|
||||
<div className="mb-3 rounded-md border border-red-800 bg-red-950/40 px-3 py-2 text-sm">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-md border border-zinc-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-zinc-950">
|
||||
<tr className="text-left">
|
||||
<th className="w-10 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allOnPageSelected}
|
||||
onChange={toggleAllOnPage}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-2">Product</th>
|
||||
<th className="px-3 py-2">Brand</th>
|
||||
<th className="px-3 py-2">Platform</th>
|
||||
<th className="px-3 py-2">Role</th>
|
||||
<th className="px-3 py-2">Import</th>
|
||||
<th className="px-3 py-2">Flags</th>
|
||||
<th className="px-3 py-2">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-6 text-center opacity-70">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && content.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-6 text-center opacity-70">
|
||||
No results.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
content.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-t border-zinc-900 hover:bg-zinc-950/40"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(row.id)}
|
||||
onChange={() => toggleOne(row.id)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{row.mainImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={row.mainImageUrl}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded object-cover border border-zinc-800"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-950" />
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{row.name}</div>
|
||||
<div className="truncate text-xs opacity-60">
|
||||
{row.slug} · #{row.id}
|
||||
</div>
|
||||
{row.adminNote ? (
|
||||
<div className="truncate text-xs opacity-60">
|
||||
📝 {row.adminNote}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-2">{row.brandName ?? "—"}</td>
|
||||
<td className="px-3 py-2">{row.platform ?? "—"}</td>
|
||||
<td className="px-3 py-2">{row.partRole ?? "—"}</td>
|
||||
<td className="px-3 py-2">{row.importStatus ?? "—"}</td>
|
||||
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1 text-xs">
|
||||
{row.visibility === "HIDDEN" ? (
|
||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
||||
HIDDEN
|
||||
</span>
|
||||
) : null}
|
||||
{row.status !== "ACTIVE" ? (
|
||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
||||
{row.status}
|
||||
</span>
|
||||
) : null}
|
||||
{row.builderEligible === false ? (
|
||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
||||
NO_BUILDER
|
||||
</span>
|
||||
) : null}
|
||||
{row.adminLocked ? (
|
||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
||||
LOCKED
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-2 text-xs opacity-70">
|
||||
{row.updatedAt ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<div className="text-sm opacity-70">
|
||||
Page {(data?.number ?? 0) + 1} of {data?.totalPages ?? 1}
|
||||
{data?.totalElements ?? 0} total
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={size}
|
||||
onChange={(e) => {
|
||||
setSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
{[25, 50, 100, 200].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}/page
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || page <= 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || (data ? page >= data.totalPages - 1 : true)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,8 +132,9 @@ 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
|
||||
// ----------------------------
|
||||
@@ -135,17 +152,42 @@ export default function PartsBrowseClient(props: {
|
||||
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");
|
||||
@@ -167,8 +218,7 @@ export default function PartsBrowseClient(props: {
|
||||
|
||||
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;
|
||||
|
||||
@@ -7,7 +7,7 @@ export const CATEGORIES: Category[] = [
|
||||
{ id: "complete-lower", name: "Complete Lower", group: "lower" },
|
||||
{ id: "lower-parts-kit", name: "Lower Parts Kit", group: "lower" },
|
||||
{ id: "trigger", name: "Trigger / Fire Control", group: "lower" },
|
||||
{ id: "grip", name: "Pistol Grip", group: "lower" },
|
||||
{ id: "grip", name: "Grips", group: "lower" },
|
||||
{ id: "safety-selector", name: "Safety / Selector", group: "lower" },
|
||||
{ id: "buffer", name: "Buffer System", group: "lower" },
|
||||
{ id: "stock", name: "Stock / Brace", group: "lower" },
|
||||
|
||||
Reference in New Issue
Block a user