fuck if i know
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// app/admin/categories/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
fetchAdminCategories,
|
||||
type AdminCategory,
|
||||
} from "@/lib/api/adminCategoryMappings";
|
||||
|
||||
export default function AdminCategoriesPage() {
|
||||
const { token } = useAuth();
|
||||
const [categories, setCategories] = useState<AdminCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const cats = await fetchAdminCategories(token);
|
||||
setCategories(cats);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to load categories");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [token]);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-500">
|
||||
You must be logged in to view this page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">
|
||||
Canonical categories
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
These are your standardized builder categories and their groupings.
|
||||
</p>
|
||||
</div>
|
||||
{/* Placeholder for future actions (add/edit) */}
|
||||
{/* <button className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-500">
|
||||
+ Add category
|
||||
</button> */}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="text-sm text-zinc-400">Loading…</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
|
||||
<table className="min-w-full text-left text-xs">
|
||||
<thead className="bg-zinc-900/80 text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Group</th>
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Slug</th>
|
||||
<th className="px-3 py-2 font-medium">Sort order</th>
|
||||
<th className="px-3 py-2 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((cat) => (
|
||||
<tr
|
||||
key={cat.id}
|
||||
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
|
||||
>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||
{cat.groupName ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-100">{cat.name}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-500">
|
||||
<code>{cat.slug}</code>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||
{cat.sortOrder ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-300">
|
||||
{cat.description || (
|
||||
<span className="text-zinc-500">No description</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{categories.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-3 py-4 text-center text-xs text-zinc-500"
|
||||
>
|
||||
No categories found. Seed the <code>part_categories</code>{" "}
|
||||
table to get started.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApi } from "@/lib/useApi";
|
||||
|
||||
type Merchant = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type CategoryMapping = {
|
||||
id: number;
|
||||
merchantId: number;
|
||||
merchantName: string;
|
||||
rawCategoryPath: string;
|
||||
partCategoryId: number | null;
|
||||
partCategoryName?: string | null;
|
||||
};
|
||||
|
||||
// Matches the Spring MerchantCategoryMappingDto record
|
||||
type MerchantCategoryMappingDto = {
|
||||
id: number;
|
||||
merchantId: number;
|
||||
merchantName: string;
|
||||
rawCategoryPath: string;
|
||||
partCategoryId: number | null;
|
||||
partCategoryName?: string | null;
|
||||
};
|
||||
|
||||
export default function MerchantCategoryMappingPage() {
|
||||
const { get, post } = useApi();
|
||||
|
||||
const [merchants, setMerchants] = useState<Merchant[]>([]);
|
||||
const [selectedMerchantId, setSelectedMerchantId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [mappings, setMappings] = useState<CategoryMapping[]>([]);
|
||||
const [loadingMerchants, setLoadingMerchants] = useState(false);
|
||||
const [loadingMappings, setLoadingMappings] = useState(false);
|
||||
const [savingId, setSavingId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 1) Load merchants for the dropdown
|
||||
useEffect(() => {
|
||||
setLoadingMerchants(true);
|
||||
setError(null);
|
||||
|
||||
get<Merchant[]>("/api/admin/category-mappings/merchants")
|
||||
.then((data) => {
|
||||
setMerchants(data);
|
||||
if (data.length > 0 && selectedMerchantId == null) {
|
||||
setSelectedMerchantId(data[0].id);
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error("Failed to load merchants", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load merchants"
|
||||
);
|
||||
})
|
||||
.finally(() => setLoadingMerchants(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 2) Load mappings when merchant changes (THIS sends merchantId)
|
||||
useEffect(() => {
|
||||
if (selectedMerchantId == null) return;
|
||||
|
||||
setLoadingMappings(true);
|
||||
setError(null);
|
||||
|
||||
get<CategoryMapping[]>(
|
||||
`/api/admin/category-mappings?merchantId=${selectedMerchantId}`
|
||||
)
|
||||
.then((data) => {
|
||||
setMappings(data);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error("Failed to load category mappings", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load category mappings"
|
||||
);
|
||||
})
|
||||
.finally(() => setLoadingMappings(false));
|
||||
}, [selectedMerchantId]);
|
||||
|
||||
// 3) Save a single mapping row (adjust endpoint if yours differs)
|
||||
const handleSaveMapping = async (
|
||||
mappingId: number,
|
||||
updated: { partCategoryId: number | null }
|
||||
) => {
|
||||
setSavingId(mappingId);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedMapping = await post<MerchantCategoryMappingDto>(
|
||||
`/api/admin/category-mappings/${mappingId}`,
|
||||
{
|
||||
partCategoryId: updated.partCategoryId,
|
||||
}
|
||||
);
|
||||
|
||||
setMappings((prev) =>
|
||||
prev.map((m) => (m.id === mappingId ? {
|
||||
...m,
|
||||
// map Java DTO fields back into your UI model
|
||||
merchantId: updatedMapping.merchantId,
|
||||
merchantName: updatedMapping.merchantName,
|
||||
rawCategoryPath: updatedMapping.rawCategoryPath,
|
||||
partCategoryId: updatedMapping.partCategoryId,
|
||||
partCategoryName: updatedMapping.partCategoryName,
|
||||
} : m))
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save mapping", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to save mapping"
|
||||
);
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-zinc-50">
|
||||
Merchant Category Mapping
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-zinc-400">Merchant:</span>
|
||||
<select
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-sm"
|
||||
value={selectedMerchantId ?? ""}
|
||||
onChange={(e) =>
|
||||
setSelectedMerchantId(
|
||||
e.target.value ? Number(e.target.value) : null
|
||||
)
|
||||
}
|
||||
>
|
||||
{loadingMerchants && (
|
||||
<option value="">Loading merchants…</option>
|
||||
)}
|
||||
{!loadingMerchants && merchants.length === 0 && (
|
||||
<option value="">No merchants</option>
|
||||
)}
|
||||
{merchants.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded bg-red-900/40 border border-red-700 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-zinc-800 rounded-lg overflow-hidden">
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-4 py-2 text-xs uppercase tracking-wide text-zinc-400">
|
||||
Category Mappings
|
||||
</div>
|
||||
|
||||
{loadingMappings ? (
|
||||
<div className="p-4 text-sm text-zinc-400">Loading mappings…</div>
|
||||
) : mappings.length === 0 ? (
|
||||
<div className="p-4 text-sm text-zinc-400">
|
||||
No mappings found for this merchant.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-zinc-950/60 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
||||
Merchant Category
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
||||
Global Category
|
||||
</th>
|
||||
<th className="px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mappings.map((m) => (
|
||||
<tr key={m.id} className="border-t border-zinc-800">
|
||||
<td className="px-4 py-2 text-zinc-100">
|
||||
{m.rawCategoryPath}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{/* For now, simple text input for partCategoryId.
|
||||
You can swap this for a select populated from /api/admin/categories */}
|
||||
<input
|
||||
type="number"
|
||||
className="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-sm w-28"
|
||||
value={m.partCategoryId ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
? Number(e.target.value)
|
||||
: null;
|
||||
setMappings((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === m.id
|
||||
? { ...row, partCategoryId: value }
|
||||
: row
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{m.partCategoryName && (
|
||||
<div className="text-xs text-zinc-500 mt-1">
|
||||
{m.partCategoryName}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSaveMapping(m.id, {
|
||||
partCategoryId: m.partCategoryId ?? null,
|
||||
})
|
||||
}
|
||||
disabled={savingId === m.id}
|
||||
className="text-xs px-3 py-1 rounded border border-zinc-600 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{savingId === m.id ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// app/admin/category-mappings/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
fetchAdminCategories,
|
||||
fetchPartRoleMappings,
|
||||
createPartRoleMapping,
|
||||
updatePartRoleMapping,
|
||||
deletePartRoleMapping,
|
||||
type AdminCategory,
|
||||
type AdminPartRoleMapping,
|
||||
} from "@/lib/api/adminCategoryMappings";
|
||||
|
||||
export default function AdminCategoryMappingsPage() {
|
||||
const { token } = useAuth();
|
||||
const [categories, setCategories] = useState<AdminCategory[]>([]);
|
||||
const [mappings, setMappings] = useState<AdminPartRoleMapping[]>([]);
|
||||
const [platform, setPlatform] = useState("AR-15");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [cats, maps] = await Promise.all([
|
||||
fetchAdminCategories(token),
|
||||
fetchPartRoleMappings(token, platform),
|
||||
]);
|
||||
setCategories(cats);
|
||||
setMappings(maps);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to load mappings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [token, platform]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!token) return;
|
||||
const defaultCategory = categories[0];
|
||||
if (!defaultCategory) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const created = await createPartRoleMapping(token, {
|
||||
platform,
|
||||
partRole: "NEW_PART_ROLE",
|
||||
categorySlug: defaultCategory.slug,
|
||||
});
|
||||
setMappings((prev) => [...prev, created]);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to create mapping");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (row: AdminPartRoleMapping, updates: Partial<AdminPartRoleMapping>) => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const updated = await updatePartRoleMapping(token, row.id, {
|
||||
platform: updates.platform ?? row.platform,
|
||||
partRole: updates.partRole ?? row.partRole,
|
||||
categorySlug: updates.categorySlug ?? row.categorySlug,
|
||||
notes: updates.notes ?? row.notes ?? undefined,
|
||||
});
|
||||
setMappings((prev) => prev.map((m) => (m.id === row.id ? updated : m)));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to update mapping");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row: AdminPartRoleMapping) => {
|
||||
if (!token) return;
|
||||
if (!confirm(`Delete mapping for ${row.partRole}?`)) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await deletePartRoleMapping(token, row.id);
|
||||
setMappings((prev) => prev.filter((m) => m.id !== row.id));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to delete mapping");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return <div className="p-6 text-sm text-red-500">You must be logged in to view this page.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold tracking-tight">
|
||||
Part role → category mappings
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
FOR LATER! Advanced mapping: defines which canonical category each builder part role
|
||||
uses for a given platform.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="AR-15">AR-15</option>
|
||||
{/* add other platforms later */}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving || loading || categories.length === 0}
|
||||
className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
+ Add mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-zinc-400">Loading…</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
|
||||
<table className="min-w-full text-left text-xs">
|
||||
<thead className="bg-zinc-900/80 text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Platform</th>
|
||||
<th className="px-3 py-2 font-medium">Part role</th>
|
||||
<th className="px-3 py-2 font-medium">Category</th>
|
||||
<th className="px-3 py-2 font-medium">Group</th>
|
||||
<th className="px-3 py-2 font-medium">Notes</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mappings.map((row) => (
|
||||
<tr key={row.id} className="border-t border-zinc-800/70 hover:bg-zinc-900/60">
|
||||
<td className="px-3 py-2 text-zinc-300">{row.platform}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
|
||||
defaultValue={row.partRole}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== row.partRole) {
|
||||
handleUpdate(row, { partRole: e.target.value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
|
||||
value={row.categorySlug}
|
||||
onChange={(e) => {
|
||||
handleUpdate(row, { categorySlug: e.target.value });
|
||||
}}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.groupName ? `[${cat.groupName}] ` : ""}
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-400 text-[11px]">{row.groupName}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
|
||||
defaultValue={row.notes ?? ""}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== (row.notes ?? "")) {
|
||||
handleUpdate(row, { notes: e.target.value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(row)}
|
||||
className="rounded border border-red-700/70 px-2 py-1 text-[11px] text-red-300 hover:bg-red-900/40"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{mappings.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-3 py-4 text-center text-xs text-zinc-500"
|
||||
>
|
||||
No mappings yet for {platform}. Click “Add mapping” to get started.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
export default function AdminHomePage() {
|
||||
const { token } = useAuth();
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-500">
|
||||
You must be logged in to view this page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Canonical categories",
|
||||
href: "admin/gunbuilder/categories",
|
||||
description:
|
||||
"Manage the core builder categories and their group/grouping + sort order.",
|
||||
},
|
||||
{
|
||||
title: "Part role mappings",
|
||||
href: "/admin/gunbuilder/part-role-mappings",
|
||||
description:
|
||||
"Advanced: map logical builder part roles to canonical categories per platform.",
|
||||
},
|
||||
{
|
||||
title: "Merchant category mappings",
|
||||
href: "/admin/gunbuilder/merchant-category-mappings",
|
||||
description:
|
||||
"Map raw merchant feed categories to your canonical categories.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-zinc-100">
|
||||
Admin
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
Internal tools to keep your builder data clean and consistent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-left hover:border-emerald-500/70 hover:bg-zinc-900/80"
|
||||
>
|
||||
<h2 className="text-sm font-medium text-zinc-100 group-hover:text-white">
|
||||
{card.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
|
||||
{card.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } 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 —" },
|
||||
// Lower / fire control
|
||||
{ value: "lower-receiver", label: "Lower Receiver" },
|
||||
{ value: "lower-parts-kit", label: "Lower Parts Kit" },
|
||||
{ value: "trigger", label: "Trigger / Trigger Kit" },
|
||||
{ value: "pistol-grip", label: "Pistol Grip" },
|
||||
{ value: "safety-selector", label: "Safety Selector" },
|
||||
|
||||
// Upper
|
||||
{ value: "upper-receiver", label: "Upper Receiver" },
|
||||
{ value: "barrel", label: "Barrel" },
|
||||
{ value: "handguard", label: "Handguard" },
|
||||
{ value: "gas-system", label: "Gas System / Gas Block" },
|
||||
{ value: "muzzle-device", label: "Muzzle Device" },
|
||||
{ value: "suppressor", label: "Suppressor" },
|
||||
|
||||
// Furniture / sights / optics
|
||||
{ value: "stock", label: "Stock" },
|
||||
{ value: "buffer-kit", label: "Buffer Kit" },
|
||||
{ value: "charging-handle", label: "Charging Handle" },
|
||||
{ value: "sight", label: "Iron / Backup Sight" },
|
||||
{ value: "optic", label: "Optic" },
|
||||
{ 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);
|
||||
const [showOnlyUnmapped, setShowOnlyUnmapped] = useState(false);
|
||||
|
||||
const sortedMappings = useMemo(() => {
|
||||
// Mapped rows first, then unmapped; within each group, alpha by rawCategory
|
||||
return [...mappings].sort((a, b) => {
|
||||
const aMapped = !!a.mappedPartRole;
|
||||
const bMapped = !!b.mappedPartRole;
|
||||
|
||||
if (aMapped !== bMapped) {
|
||||
return aMapped ? -1 : 1; // mapped first
|
||||
}
|
||||
|
||||
return a.rawCategory.localeCompare(b.rawCategory);
|
||||
});
|
||||
}, [mappings]);
|
||||
|
||||
const visibleMappings = useMemo(
|
||||
() =>
|
||||
showOnlyUnmapped
|
||||
? sortedMappings.filter((m) => !m.mappedPartRole)
|
||||
: sortedMappings,
|
||||
[sortedMappings, showOnlyUnmapped],
|
||||
);
|
||||
|
||||
// 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'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 flex items-center justify-between gap-3 text-xs text-zinc-500">
|
||||
<div>
|
||||
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>
|
||||
<label className="inline-flex items-center gap-1 text-[0.7rem] text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900"
|
||||
checked={showOnlyUnmapped}
|
||||
onChange={(e) => setShowOnlyUnmapped(e.target.checked)}
|
||||
/>
|
||||
<span>Show only unmapped</span>
|
||||
</label>
|
||||
</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>
|
||||
<th className="py-2 text-left pr-4">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleMappings.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>
|
||||
<td className="py-2 pr-4 align-top text-sm">
|
||||
{m.mappedPartRole ? (
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[0.7rem] font-medium text-emerald-300">
|
||||
Mapped
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-full bg-zinc-700/40 px-2 py-0.5 text-[0.7rem] font-medium text-zinc-300">
|
||||
Unmapped / Ignored
|
||||
</span>
|
||||
)}
|
||||
</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
-2
@@ -3,14 +3,13 @@
|
||||
import "./globals.css";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider } from "@/context/AuthContext";
|
||||
import { TopNav } from "@/components/TopNav"; // or default import if that's how you exported it
|
||||
import { TopNav } from "@/components/TopNav";
|
||||
import { BuilderNav } from "@/components/BuilderNav";
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-black text-zinc-100">
|
||||
{/* AuthProvider wraps everything that uses useAuth */}
|
||||
<AuthProvider>
|
||||
<TopNav />
|
||||
<BuilderNav />
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
export function useApi() {
|
||||
const { token } = useAuth();
|
||||
|
||||
async function get(path: string) {
|
||||
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function post(path: string, body: any) {
|
||||
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
return { get, post };
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { apiFetch } from "@/lib/useApi";
|
||||
|
||||
export type AdminCategory = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
groupName?: string | null;
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
export type AdminPartRoleMapping = {
|
||||
id: number;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
categorySlug: string;
|
||||
categoryName: string;
|
||||
groupName?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchAdminCategories(token: string) {
|
||||
return apiFetch<AdminCategory[]>("/api/admin/categories", {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPartRoleMappings(token: string, platform = "AR-15") {
|
||||
const params = new URLSearchParams({ platform });
|
||||
return apiFetch<AdminPartRoleMapping[]>(`/api/admin/category-mappings?${params.toString()}`, {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPartRoleMapping(
|
||||
token: string,
|
||||
payload: {
|
||||
platform: string;
|
||||
partRole: string;
|
||||
categorySlug: string;
|
||||
notes?: string;
|
||||
},
|
||||
) {
|
||||
return apiFetch<AdminPartRoleMapping>("/api/admin/category-mappings", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePartRoleMapping(
|
||||
token: string,
|
||||
id: number,
|
||||
payload: {
|
||||
platform: string;
|
||||
partRole: string;
|
||||
categorySlug: string;
|
||||
notes?: string;
|
||||
},
|
||||
) {
|
||||
return apiFetch<AdminPartRoleMapping>(`/api/admin/category-mappings/${id}`, {
|
||||
method: "PUT",
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePartRoleMapping(token: string, id: number) {
|
||||
await apiFetch<void>(`/api/admin/category-mappings/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// lib/auth-client.ts
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName?: string | null;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type AuthPayload = {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
userId: number;
|
||||
email: string;
|
||||
displayName?: string | null;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type RegisterInput = {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
export type LoginInput = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const AUTH_STORAGE_KEY = "armory-auth";
|
||||
|
||||
export function storeAuth(payload: AuthPayload) {
|
||||
if (typeof window === "undefined") return;
|
||||
const user: AuthUser = {
|
||||
id: payload.userId,
|
||||
email: payload.email,
|
||||
displayName: payload.displayName,
|
||||
role: payload.role,
|
||||
};
|
||||
|
||||
const toStore = {
|
||||
user,
|
||||
accessToken: payload.accessToken,
|
||||
refreshToken: payload.refreshToken ?? null,
|
||||
};
|
||||
|
||||
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(toStore));
|
||||
}
|
||||
|
||||
export function loadStoredAuth():
|
||||
| { user: AuthUser; accessToken: string; refreshToken: string | null }
|
||||
| null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed.user || !parsed.accessToken) return null;
|
||||
return {
|
||||
user: parsed.user as AuthUser,
|
||||
accessToken: parsed.accessToken as string,
|
||||
refreshToken: parsed.refreshToken ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredAuth() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
}
|
||||
|
||||
async function handleAuthResponse(res: Response): Promise<AuthPayload> {
|
||||
if (!res.ok) {
|
||||
let message = `Request failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.message) message = body.message;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return (await res.json()) as AuthPayload;
|
||||
}
|
||||
|
||||
export async function apiRegister(input: RegisterInput): Promise<AuthPayload> {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return handleAuthResponse(res);
|
||||
}
|
||||
|
||||
export async function apiLogin(input: LoginInput): Promise<AuthPayload> {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return handleAuthResponse(res);
|
||||
}
|
||||
Reference in New Issue
Block a user