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
+46 -29
View File
@@ -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
View File
@@ -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 dont 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"
>
&lt;
</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"
>
&gt;
</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>
);
}
+66 -19
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,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;
+1 -1
View File
@@ -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" },