admin page for category mappings. fixed some bugs and other tweaks

This commit is contained in:
2025-12-01 06:48:34 -05:00
parent ba4ac2eaf6
commit 309d164139
122 changed files with 3190 additions and 17 deletions
+4 -1
View File
@@ -30,6 +30,9 @@ const CATEGORY_TO_PART_ROLES: Record<CategoryId, string[]> = {
buffer: ["buffer-kit"],
lowerParts: ["lower-parts-kit"],
sights: ["sight"],
lower: ["lower-receiver"],
optic: ["optic"],
stock: ["stock"],
};
// sort options
@@ -239,7 +242,7 @@ export default function CategoryPage() {
href="/gunbuilder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to Gunbuilder
Back to The Armory
</Link>
<div className="flex items-start justify-between gap-4">
<div>
@@ -0,0 +1,298 @@
"use client";
import { useEffect, useState } from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type Merchant = {
id: number;
name: string;
};
type Mapping = {
id: number | null; // might be null if just created client-side
merchantId: number;
merchantName: string;
rawCategory: string;
mappedPartRole: string | null;
};
type UpsertRequest = {
merchantId: number;
rawCategory: string;
mappedPartRole: string | null;
};
// Keep this in sync with your backend `partRole` values
const PART_ROLE_OPTIONS = [
{ value: "", label: "— Unmapped —" },
{ value: "upper-receiver", label: "Upper Receiver" },
{ value: "barrel", label: "Barrel" },
{ value: "handguard", label: "Handguard" },
{ value: "charging-handle", label: "Charging Handle" },
{ value: "buffer-kit", label: "Buffer Kit" },
{ value: "lower-parts-kit", label: "Lower Parts Kit" },
{ value: "sight", label: "Sight / Optic" },
{ value: "lower-receiver", label: "Lower Receiver" },
{ value: "optic", label: "Optic" },
{ value: "stock", label: "Stock" },
{ value: "other", label: "Other / Ignore" },
];
export default function CategoryMappingsPage() {
const [merchants, setMerchants] = useState<Merchant[]>([]);
const [selectedMerchantId, setSelectedMerchantId] = useState<number | null>(
null,
);
const [mappings, setMappings] = useState<Mapping[]>([]);
const [loadingMappings, setLoadingMappings] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Load merchants
useEffect(() => {
async function fetchMerchants() {
try {
const res = await fetch(`${API_BASE_URL}/admin/merchants`);
if (!res.ok) {
throw new Error(`Failed to load merchants (${res.status})`);
}
const data: Merchant[] = await res.json();
setMerchants(data);
// Auto-select the first merchant if you want
if (data.length > 0 && selectedMerchantId === null) {
setSelectedMerchantId(data[0].id);
}
} catch (err: any) {
console.error(err);
setError(err.message ?? "Failed to load merchants");
}
}
fetchMerchants();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load mappings for selected merchant
useEffect(() => {
if (!selectedMerchantId) return;
const controller = new AbortController();
async function fetchMappings() {
try {
setLoadingMappings(true);
setError(null);
setSuccessMessage(null);
const url = `${API_BASE_URL}/admin/merchant-category-mappings?merchantId=${selectedMerchantId}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load mappings (${res.status})`);
}
const data = (await res.json()) as Mapping[];
setMappings(data);
} catch (err: any) {
if (err.name === "AbortError") return;
console.error(err);
setError(err.message ?? "Failed to load mappings");
} finally {
setLoadingMappings(false);
}
}
fetchMappings();
return () => controller.abort();
}, [selectedMerchantId]);
const handleChangePartRole = (rawCategory: string, newRole: string) => {
setMappings((prev) =>
prev.map((m) =>
m.rawCategory === rawCategory
? { ...m, mappedPartRole: newRole || null }
: m,
),
);
};
const handleSave = async () => {
if (!selectedMerchantId) return;
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
// Save each mapping in parallel
const requests: Promise<Response>[] = mappings.map((m) => {
const payload: UpsertRequest = {
merchantId: selectedMerchantId,
rawCategory: m.rawCategory,
mappedPartRole: m.mappedPartRole,
};
return fetch(`${API_BASE_URL}/admin/merchant-category-mappings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
});
const responses = await Promise.all(requests);
const failed = responses.filter((r) => !r.ok);
if (failed.length > 0) {
throw new Error(`Some mappings failed to save (${failed.length})`);
}
setSuccessMessage("Mappings saved. Re-run the import to apply changes.");
} catch (err: any) {
console.error(err);
setError(err.message ?? "Failed to save mappings");
} finally {
setSaving(false);
}
};
const selectedMerchantName =
merchants.find((m) => m.id === selectedMerchantId)?.name ?? "—";
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
<header className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard / Admin
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Merchant Category Mapping
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Normalize each merchant&apos;s category names into your internal
part roles (upper, barrel, handguard, etc.). Unmapped categories
will be skipped during import until they are mapped here.
</p>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-zinc-400 mb-1">
Select Merchant
</label>
<select
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
value={selectedMerchantId ?? ""}
onChange={(e) =>
setSelectedMerchantId(
e.target.value ? Number(e.target.value) : null,
)
}
>
<option value=""> Choose merchant </option>
{merchants.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
</header>
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
{error && (
<div className="mb-3 rounded border border-red-500/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{successMessage && (
<div className="mb-3 rounded border border-emerald-500/60 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200">
{successMessage}
</div>
)}
{!selectedMerchantId ? (
<p className="text-sm text-zinc-500">
Choose a merchant above to view category mappings.
</p>
) : loadingMappings ? (
<p className="text-sm text-zinc-500">
Loading mappings for <span className="text-zinc-300">{selectedMerchantName}</span>
</p>
) : mappings.length === 0 ? (
<p className="text-sm text-zinc-500">
No category rows yet for this merchant. Once you run an import,
discovered categories will appear here automatically.
</p>
) : (
<>
<div className="mb-3 text-xs text-zinc-500">
Mappings for <span className="text-zinc-300">{selectedMerchantName}</span>.{" "}
Set a part role for each raw category. Unmapped categories will
be skipped by the importer.
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
<th className="py-2 text-left pr-4">Raw Category</th>
<th className="py-2 text-left pr-4">Mapped Part Role</th>
</tr>
</thead>
<tbody>
{mappings.map((m) => (
<tr
key={m.rawCategory}
className="border-b border-zinc-900 last:border-b-0"
>
<td className="py-2 pr-4 align-top text-zinc-200">
{m.rawCategory}
</td>
<td className="py-2 pr-4 align-top">
<select
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
value={m.mappedPartRole ?? ""}
onChange={(e) =>
handleChangePartRole(
m.rawCategory,
e.target.value,
)
}
>
{PART_ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
>
{saving ? "Saving…" : "Save Mappings"}
</button>
</div>
</>
)}
</section>
</div>
</main>
);
}
+1 -1
View File
@@ -232,7 +232,7 @@ export default function BuildDetailsPage() {
href="/gunbuilder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to Gunbuilder
Back to The Armory
</Link>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
+5 -2
View File
@@ -30,6 +30,9 @@ const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
"buffer-kit": "buffer" as CategoryId,
"lower-parts-kit": "lowerParts" as CategoryId,
sight: "sights" as CategoryId,
"lower-receiver": "lower" as CategoryId,
optic: "optic" as CategoryId,
stock: "stock" as CategoryId,
};
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
@@ -177,7 +180,7 @@ export default function GunbuilderPage() {
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Gunbuilder <span className="text-amber-300">Prototype</span>
The Armory <span className="text-amber-300">Prototype</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Choose one part per category to assemble an AR-15 build. Parts are
@@ -288,7 +291,7 @@ export default function GunbuilderPage() {
: ""
}`}
>
Continue to Buy
Build Summary
</Link>
<button
type="button"