Files
shadow-gunbuilder-ai-proto/app/admin/products/page.tsx
T

605 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<T> = {
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<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() {
// 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">("");
// 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 (
<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>
</div>
<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>
{/* 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>
);
}