"use client"; import { useEffect, useMemo, useState } from "react"; type ProductVisibility = "PUBLIC" | "HIDDEN"; type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED"; type AdminProductRow = { id: number; uuid: string; name: string; 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; }; type PageResponse = { content: T[]; totalElements: number; totalPages: number; number: number; size: number; }; const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; 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; } function getToken(): string | null { if (typeof window === "undefined") return null; // 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) { 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) { 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; } 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() { // filters const [q, setQ] = useState(""); const [platform, setPlatform] = useState(""); const [partRole, setPartRole] = useState(""); const [visibility, setVisibility] = useState(""); const [status, setStatus] = useState(""); const [builderEligible, setBuilderEligible] = useState<"" | "true" | "false">( "" ); const [adminLocked, setAdminLocked] = useState<"" | "true" | "false">(""); // paging const [page, setPage] = useState(0); const [size, setSize] = useState(50); // data const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); // selection const [selected, setSelected] = useState>(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[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 (

Admin · Products

Filter, select, and apply bulk actions. This is where bad imports come to die.

{/* Filters */}
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" /> setPlatform(e.target.value)} placeholder="Platform (AR-15)…" className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm" /> setPartRole(e.target.value)} placeholder="Part role (barrel)…" className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm" />
{/* Bulk actions */}
Selected:{" "} {selectedIds.length}
{/* Errors */} {err && (
{err}
)} {/* Table */}
{loading && ( )} {!loading && content.length === 0 && ( )} {!loading && content.map((row) => ( ))}
Product Brand Platform Role Import Flags Updated
Loading…
No results.
toggleOne(row.id)} />
{row.mainImageUrl ? ( // eslint-disable-next-line @next/next/no-img-element ) : (
)}
{row.name}
{row.slug} · #{row.id}
{row.adminNote ? (
📝 {row.adminNote}
) : null}
{row.brandName ?? "—"} {row.platform ?? "—"} {row.partRole ?? "—"} {row.importStatus ?? "—"}
{row.visibility === "HIDDEN" ? ( HIDDEN ) : null} {row.status !== "ACTIVE" ? ( {row.status} ) : null} {row.builderEligible === false ? ( NO_BUILDER ) : null} {row.adminLocked ? ( LOCKED ) : null}
{row.updatedAt ?? "—"}
{/* Pagination */}
Page {(data?.number ?? 0) + 1} of {data?.totalPages ?? 1} {data?.totalElements ?? 0} total
); }