411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
type ProductSummary = {
|
|
id: number;
|
|
name: string;
|
|
brand: string;
|
|
platform: string;
|
|
partRole: string;
|
|
price: number | null;
|
|
buyUrl: string | null;
|
|
imageUrl: string | null;
|
|
};
|
|
|
|
const API_BASE_URL =
|
|
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"
|
|
};
|
|
|
|
// 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;
|
|
};
|
|
|
|
const FALLBACK_PLATFORMS: PlatformOption[] = [
|
|
{ key: "AR15", label: "AR-15" },
|
|
{ key: "AR10", label: "AR-10" },
|
|
{ key: "AR9", label: "AR-9" },
|
|
];
|
|
|
|
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]
|
|
);
|
|
|
|
const paginated = useMemo(() => {
|
|
return filtered.slice(pageStart, pageEndExclusive);
|
|
}, [filtered, pageStart, pageEndExclusive]);
|
|
|
|
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
|
|
</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>
|
|
|
|
<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>
|
|
</div>
|
|
</main>
|
|
);
|
|
} |