"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"; // ----------------------------- // Auth helpers (kept as-is) // ----------------------------- 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; const ls = localStorage.getItem("ballistic_auth_token"); if (ls && ls.trim().length > 0) return ls; const ck = getCookie("bb_access_token"); if (ck && ck.trim().length > 0) return ck; return 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; platform: string; platformLocked: boolean; }> ) { 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; skippedLockedCount: number; }; } // ----------------------------- // UI types // ----------------------------- type AdminFilters = { q: string; platform: string; // platform KEY partRole: string; visibility: ProductVisibility | ""; status: ProductStatus | ""; builderEligible: "" | "true" | "false"; adminLocked: "" | "true" | "false"; }; type PlatformDto = { id: number; key: string; label: string; is_active: boolean; }; async function fetchPlatforms() { const res = await fetch(`${API_BASE}/api/platforms`, { method: "GET", headers: { Accept: "application/json" }, cache: "no-store", }); if (!res.ok) { const txt = await res.text(); throw new Error(`Failed to load platforms (${res.status}): ${txt}`); } const all = (await res.json()) as PlatformDto[]; return all.filter((p) => p.is_active); } // Replace with an API later if you want. // For now: derive from the current page results + known “common” roles. function buildRoleOptions(rows: AdminProductRow[]) { const set = new Set(); rows.forEach((r) => { if (r.partRole) set.add(r.partRole); }); return ["", ...Array.from(set).sort((a, b) => a.localeCompare(b))]; } // ----------------------------- // Small UI helpers // ----------------------------- function Field({ label, children, widthClass, }: { label: string; children: React.ReactNode; widthClass?: string; }) { return (
{label}
{children}
); } function Chip({ text, onClear }: { text: string; onClear: () => void }) { return ( ); } export default function AdminProductsPage() { // paging const [page, setPage] = useState(0); const [size, setSize] = useState(50); // dropdown data const [platformOptions, setPlatformOptions] = useState([]); // data const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); // selection const [selected, setSelected] = useState>(new Set()); // filters: draft vs applied (THIS is the big clarity win) const initialFilters: AdminFilters = { q: "", platform: "", partRole: "", visibility: "", status: "", builderEligible: "", adminLocked: "", }; const [draft, setDraft] = useState(initialFilters); const [applied, setApplied] = useState(initialFilters); // bulk action UI state const [bulkPlatformKey, setBulkPlatformKey] = useState(""); const [bulkPlatformLock, setBulkPlatformLock] = useState(false); const queryParams = useMemo(() => { return { ...applied, builderEligible: applied.builderEligible === "" ? null : applied.builderEligible, adminLocked: applied.adminLocked === "" ? null : applied.adminLocked, page, size, sort: "updatedAt,desc", }; }, [applied, page, size]); const refresh = async () => { setLoading(true); setErr(null); try { const res = await fetchAdminProducts(queryParams); setData(res); setSelected(new Set()); // prevent accidental “bulk” on new results } catch (e: any) { setErr(e?.message ?? "Failed to load."); } finally { setLoading(false); } }; // auto-refresh only on paging (kept behavior) useEffect(() => { refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, size]); // load platforms useEffect(() => { (async () => { try { const plats = await fetchPlatforms(); plats.sort((a, b) => a.label.localeCompare(b.label)); setPlatformOptions(plats); } catch (e: any) { console.warn(e?.message ?? "Failed to load platforms"); } })(); }, []); const content = data?.content ?? []; // derive role options from current page (good enough v1) const roleOptions = useMemo(() => buildRoleOptions(content), [content]); const selectedIds = Array.from(selected); 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 applyFilters = async () => { setPage(0); setApplied({ ...draft }); // keep your “manual” refresh model: await refresh(); }; const clearFilters = async () => { setDraft(initialFilters); setApplied(initialFilters); setPage(0); await refresh(); }; const runBulk = async (set: Parameters[1]) => { if (selectedIds.length === 0) return; setLoading(true); setErr(null); try { const res = await bulkUpdate(selectedIds, set); console.log( `Updated ${res.updatedCount}` + (res.skippedLockedCount > 0 ? ` • Skipped ${res.skippedLockedCount} (platform locked)` : "") ); await refresh(); if ("platform" in set) { setBulkPlatformKey(""); setBulkPlatformLock(false); } } catch (e: any) { setErr(e?.message ?? "Bulk update failed."); setLoading(false); } }; // active filter chips (optional but huge clarity) const chips = useMemo(() => { const out: { key: keyof AdminFilters; label: string }[] = []; if (applied.q) out.push({ key: "q", label: `Search: ${applied.q}` }); if (applied.platform) out.push({ key: "platform", label: `Platform: ${applied.platform}` }); if (applied.partRole) out.push({ key: "partRole", label: `Role: ${applied.partRole}` }); if (applied.visibility) out.push({ key: "visibility", label: `Visibility: ${applied.visibility}`, }); if (applied.status) out.push({ key: "status", label: `Status: ${applied.status}` }); if (applied.builderEligible) out.push({ key: "builderEligible", label: `Builder: ${applied.builderEligible}`, }); if (applied.adminLocked) out.push({ key: "adminLocked", label: `Locked: ${applied.adminLocked}` }); return out; }, [applied]); const clearOne = async (key: keyof AdminFilters) => { const nextDraft = { ...draft, [key]: "" } as AdminFilters; const nextApplied = { ...applied, [key]: "" } as AdminFilters; setDraft(nextDraft); setApplied(nextApplied); setPage(0); await refresh(); }; return (

Admin · Products

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

{/* ROW 1: FILTER BAR */}
setDraft((s) => ({ ...s, q: e.target.value }))} placeholder="name, slug, mpn, upc…" className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm" />
{chips.length > 0 && (
{chips.map((c) => ( void clearOne(c.key)} /> ))}
)}
{/* ROW 2: BULK BAR (only when selection > 0) */} {selectedIds.length > 0 && (
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
); }