fuck if i know

This commit is contained in:
2025-12-04 15:06:57 -05:00
parent fb3c23fa46
commit cb16698940
76 changed files with 1197 additions and 603 deletions
+17 -2
View File
@@ -1,9 +1,9 @@
{
"pages": {
"/gunbuilder/page": [
"/admin/gunbuilder/part-role-mappings/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/gunbuilder/page.js"
"static/chunks/app/admin/gunbuilder/part-role-mappings/page.js"
],
"/layout": [
"static/chunks/webpack.js",
@@ -11,6 +11,21 @@
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/admin/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/admin/page.js"
],
"/gunbuilder/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/gunbuilder/page.js"
],
"/admin/gunbuilder/merchant-category-mappings/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/admin/gunbuilder/merchant-category-mappings/page.js"
],
"/_not-found/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -1
View File
@@ -1,4 +1,7 @@
{
"/_not-found/page": "app/_not-found/page.js",
"/gunbuilder/page": "app/gunbuilder/page.js"
"/admin/gunbuilder/part-role-mappings/page": "app/admin/gunbuilder/part-role-mappings/page.js",
"/gunbuilder/page": "app/gunbuilder/page.js",
"/admin/page": "app/admin/page.js",
"/admin/gunbuilder/merchant-category-mappings/page": "app/admin/gunbuilder/merchant-category-mappings/page.js"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"1xFGN5nb3mbeAQPPAywQxDx1Ue0O/Rg+NJlfm52KEi4=\"\n}"
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"8FrZQAkdSCaOE1KMpFoj5rwrLq2kj7eoZ32XbwDLK30=\"\n}"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "1xFGN5nb3mbeAQPPAywQxDx1Ue0O/Rg+NJlfm52KEi4="
"encryptionKey": "8FrZQAkdSCaOE1KMpFoj5rwrLq2kj7eoZ32XbwDLK30="
}
+1 -1
View File
@@ -125,7 +125,7 @@
/******/
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("2d054b747f45013d")
/******/ __webpack_require__.h = () => ("37970ea9607f304f")
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -192,7 +192,7 @@
/******/
/******/ /* webpack/runtime/getFullHash */
/******/ !function() {
/******/ __webpack_require__.h = function() { return "949aaca34b45e9ed"; }
/******/ __webpack_require__.h = function() { return "a0aa921b182f0e3a"; }
/******/ }();
/******/
/******/ /* webpack/runtime/global */
+81 -19
View File
@@ -605,9 +605,6 @@ video {
.hidden {
display: none;
}
.h-3 {
height: 0.75rem;
}
.h-32 {
height: 8rem;
}
@@ -626,8 +623,8 @@ video {
.min-h-screen {
min-height: 100vh;
}
.w-3 {
width: 0.75rem;
.w-28 {
width: 7rem;
}
.w-4 {
width: 1rem;
@@ -818,9 +815,6 @@ video {
.border-emerald-500\/40 {
border-color: rgb(16 185 129 / 0.4);
}
.border-emerald-500\/60 {
border-color: rgb(16 185 129 / 0.6);
}
.border-green-500 {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity));
@@ -846,6 +840,13 @@ video {
.border-red-500\/60 {
border-color: rgb(239 68 68 / 0.6);
}
.border-red-700 {
--tw-border-opacity: 1;
border-color: rgb(185 28 28 / var(--tw-border-opacity));
}
.border-red-700\/70 {
border-color: rgb(185 28 28 / 0.7);
}
.border-zinc-600 {
--tw-border-opacity: 1;
border-color: rgb(82 82 91 / var(--tw-border-opacity));
@@ -858,6 +859,9 @@ video {
--tw-border-opacity: 1;
border-color: rgb(39 39 42 / var(--tw-border-opacity));
}
.border-zinc-800\/70 {
border-color: rgb(39 39 42 / 0.7);
}
.border-zinc-900 {
--tw-border-opacity: 1;
border-color: rgb(24 24 27 / var(--tw-border-opacity));
@@ -888,6 +892,10 @@ video {
.bg-emerald-500\/10 {
background-color: rgb(16 185 129 / 0.1);
}
.bg-emerald-600 {
--tw-bg-opacity: 1;
background-color: rgb(5 150 105 / var(--tw-bg-opacity));
}
.bg-green-500\/10 {
background-color: rgb(34 197 94 / 0.1);
}
@@ -901,8 +909,11 @@ video {
.bg-red-500\/20 {
background-color: rgb(239 68 68 / 0.2);
}
.bg-zinc-700\/40 {
background-color: rgb(63 63 70 / 0.4);
.bg-red-900\/40 {
background-color: rgb(127 29 29 / 0.4);
}
.bg-red-950\/30 {
background-color: rgb(69 10 10 / 0.3);
}
.bg-zinc-800 {
--tw-bg-opacity: 1;
@@ -959,6 +970,9 @@ video {
.p-4 {
padding: 1rem;
}
.p-6 {
padding: 1.5rem;
}
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
@@ -1046,9 +1060,6 @@ video {
.pr-3 {
padding-right: 0.75rem;
}
.pr-4 {
padding-right: 1rem;
}
.pr-6 {
padding-right: 1.5rem;
}
@@ -1124,6 +1135,9 @@ video {
.leading-none {
line-height: 1;
}
.leading-snug {
line-height: 1.375;
}
.tracking-\[0\.15em\] {
letter-spacing: 0.15em;
}
@@ -1172,9 +1186,9 @@ video {
--tw-text-opacity: 1;
color: rgb(167 243 208 / var(--tw-text-opacity));
}
.text-emerald-300 {
.text-emerald-400 {
--tw-text-opacity: 1;
color: rgb(110 231 183 / var(--tw-text-opacity));
color: rgb(52 211 153 / var(--tw-text-opacity));
}
.text-green-400 {
--tw-text-opacity: 1;
@@ -1200,10 +1214,18 @@ video {
--tw-text-opacity: 1;
color: rgb(254 202 202 / var(--tw-text-opacity));
}
.text-red-300 {
--tw-text-opacity: 1;
color: rgb(252 165 165 / var(--tw-text-opacity));
}
.text-red-400 {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
}
.text-red-500 {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity));
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -1302,14 +1324,14 @@ body {
color: rgb(250 250 250 / var(--tw-text-opacity));
}
.last\:border-b-0:last-child {
border-bottom-width: 0px;
}
.hover\:border-amber-400\/60:hover {
border-color: rgb(251 191 36 / 0.6);
}
.hover\:border-emerald-500\/70:hover {
border-color: rgb(16 185 129 / 0.7);
}
.hover\:border-zinc-500:hover {
--tw-border-opacity: 1;
border-color: rgb(113 113 122 / var(--tw-border-opacity));
@@ -1341,11 +1363,20 @@ body {
background-color: rgb(251 191 36 / 0.3);
}
.hover\:bg-emerald-500:hover {
--tw-bg-opacity: 1;
background-color: rgb(16 185 129 / var(--tw-bg-opacity));
}
.hover\:bg-neutral-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(38 38 38 / var(--tw-bg-opacity));
}
.hover\:bg-red-900\/40:hover {
background-color: rgb(127 29 29 / 0.4);
}
.hover\:bg-zinc-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(63 63 70 / var(--tw-bg-opacity));
@@ -1360,11 +1391,20 @@ body {
background-color: rgb(24 24 27 / 0.6);
}
.hover\:bg-zinc-900\/80:hover {
background-color: rgb(24 24 27 / 0.8);
}
.hover\:text-amber-200:hover {
--tw-text-opacity: 1;
color: rgb(253 230 138 / var(--tw-text-opacity));
}
.hover\:text-emerald-300:hover {
--tw-text-opacity: 1;
color: rgb(110 231 183 / var(--tw-text-opacity));
}
.hover\:text-white:hover {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -1385,6 +1425,11 @@ body {
color: rgb(212 212 216 / var(--tw-text-opacity));
}
.hover\:text-zinc-50:hover {
--tw-text-opacity: 1;
color: rgb(250 250 250 / var(--tw-text-opacity));
}
.focus\:border-amber-400\/70:focus {
border-color: rgb(251 191 36 / 0.7);
}
@@ -1423,16 +1468,29 @@ body {
visibility: visible;
}
.group:hover .group-hover\:text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.group:hover .group-hover\:opacity-100 {
opacity: 1;
}
@media (min-width: 640px) {
.sm\:inline {
display: inline;
}
.sm\:w-auto {
width: auto;
}
.sm\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sm\:flex-row {
flex-direction: row;
}
@@ -1464,6 +1522,10 @@ body {
display: inline;
}
.md\:flex {
display: flex;
}
.md\:inline-flex {
display: inline-flex;
}
@@ -1 +0,0 @@
{"c":["app/layout","webpack"],"r":[],"m":[]}
@@ -1,22 +0,0 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("app/layout",{
/***/ "(app-pages-browser)/./app/globals.css":
/*!*************************!*\
!*** ./app/globals.css ***!
\*************************/
/***/ (function(module, __webpack_exports__, __webpack_require__) {
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"32ab11c94444\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL2FwcC9nbG9iYWxzLmNzcyIsIm1hcHBpbmdzIjoiO0FBQUEsK0RBQWUsY0FBYztBQUM3QixJQUFJLElBQVUsSUFBSSxpQkFBaUIiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9fTl9FLy4vYXBwL2dsb2JhbHMuY3NzPzk1YWEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCIzMmFiMTFjOTQ0NDRcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(app-pages-browser)/./app/globals.css\n"));
/***/ })
});
@@ -1,18 +0,0 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ !function() {
/******/ __webpack_require__.h = function() { return "e6c287ee061905c3"; }
/******/ }();
/******/
/******/ }
);
+8 -4
View File
File diff suppressed because one or more lines are too long
+125
View File
@@ -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>
);
}
+67
View File
@@ -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&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 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
View File
@@ -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 />
-73
View File
@@ -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,
});
}
+27 -3
View File
@@ -7,8 +7,11 @@ import { useAuth } from "@/context/AuthContext";
export function TopNav() {
const { user, logout, loading } = useAuth();
const isAuthenticated = !!user;
return (
<header className="border-b border-zinc-800 bg-black/95 backdrop-blur">
{/* Early access bar */}
<div className="border-b border-amber-500/20 bg-amber-500/5">
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
@@ -25,8 +28,10 @@ export function TopNav() {
</span>
</div>
</div>
{/* Main nav row */}
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
{/* Brand / Home link */}
{/* Left: Brand / Home */}
<Link
href="/"
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
@@ -34,6 +39,25 @@ export function TopNav() {
The Build Bench
</Link>
{/* Center: main nav (Builder / Admin) */}
<nav className="hidden items-center gap-4 md:flex">
<Link
href="/gunbuilder"
className="text-xs font-medium text-zinc-300 hover:text-zinc-50 transition-colors"
>
Builder
</Link>
{isAuthenticated && (
<Link
href="/admin"
className="text-xs font-medium text-emerald-400 hover:text-emerald-300 transition-colors"
>
Admin
</Link>
)}
</nav>
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Search placeholder */}
@@ -59,9 +83,9 @@ export function TopNav() {
{loading ? (
<span className="text-xs text-zinc-500">Checking session</span>
) : user ? (
) : isAuthenticated ? (
<>
<span className="text-xs text-zinc-300">
<span className="hidden text-xs text-zinc-300 sm:inline">
{user.displayName || user.email}
</span>
<button
+86 -83
View File
@@ -20,8 +20,8 @@ type AuthUser = {
type AuthContextValue = {
user: AuthUser;
token: string | null;
loading: boolean; // covers initial hydrate + active auth requests
token: string | null;
loading: boolean;
login: (params: { email: string; password: string }) => Promise<void>;
register: (params: {
email: string;
@@ -38,136 +38,138 @@ const TOKEN_KEY = "ballistic_auth_token";
const USER_KEY = "ballistic_auth_user";
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // start true while we hydrate + reuse for calls
const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState<boolean>(true);
// hydrate from localStorage
// Hydrate from localStorage on first load
useEffect(() => {
try {
if (typeof window === "undefined") return;
if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY);
const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY);
if (storedToken && storedUser) {
setToken(storedToken);
if (storedToken) {
setToken(storedToken);
}
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch {
// bad JSON? wipe it
window.localStorage.removeItem(USER_KEY);
}
} catch {
// ignore
} finally {
setLoading(false);
}
setLoading(false);
}, []);
const setSession = useCallback((newToken: string, newUser: AuthUser) => {
setToken(newToken);
setUser(newUser);
const persistAuth = useCallback((nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return;
if (typeof window !== "undefined") {
window.localStorage.setItem(TOKEN_KEY, newToken);
window.localStorage.setItem(USER_KEY, JSON.stringify(newUser));
}
}, []);
const clearSession = useCallback(() => {
setToken(null);
setUser(null);
if (typeof window !== "undefined") {
if (nextToken) {
window.localStorage.setItem(TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
if (nextUser) {
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else {
window.localStorage.removeItem(USER_KEY);
}
}, []);
const register = useCallback(
async (params: { email: string; password: string; displayName?: string }) => {
const login = useCallback(
async ({ email, password }: { email: string; password: string }) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
const res = await fetch(`${API_BASE_URL}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: params.email,
password: params.password,
displayName: params.displayName,
}),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to create account");
const text = await res.text().catch(() => res.statusText);
throw new Error(text || "Login failed");
}
const data: {
token: string;
email: string;
displayName?: string;
role: string;
} = await res.json();
const data = await res.json();
setSession(data.token, {
email: data.email,
displayName: data.displayName ?? null,
role: data.role,
});
// Adjust these to match your backend response shape
const nextToken: string = data.token ?? data.accessToken;
const nextUser: AuthUser =
data.user ??
({
email: data.email ?? email,
displayName: data.displayName ?? null,
role: data.role ?? "USER",
} as AuthUser);
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
} finally {
setLoading(false);
}
},
[setSession],
[persistAuth]
);
const login = useCallback(
async (params: { email: string; password: string }) => {
const register = useCallback(
async ({
email,
password,
displayName,
}: {
email: string;
password: string;
displayName?: string;
}) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
const res = await fetch(`${API_BASE_URL}/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: params.email,
password: params.password,
}),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, displayName }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to log in");
const text = await res.text().catch(() => res.statusText);
throw new Error(text || "Registration failed");
}
const data: {
token: string;
email: string;
displayName?: string;
role: string;
} = await res.json();
const data = await res.json();
setSession(data.token, {
email: data.email,
displayName: data.displayName ?? null,
role: data.role,
});
const nextToken: string = data.token ?? data.accessToken;
const nextUser: AuthUser =
data.user ??
({
email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null,
role: data.role ?? "USER",
} as AuthUser);
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
} finally {
setLoading(false);
}
},
[setSession],
[persistAuth]
);
const logout = useCallback(() => {
clearSession();
}, [clearSession]);
setToken(null);
setUser(null);
persistAuth(null, null);
}, [persistAuth]);
const getAuthHeaders = useCallback((): HeadersInit => {
if (!token) return {};
return {
Authorization: `Bearer ${token}`,
};
return token ? { Authorization: `Bearer ${token}` } : {};
}, [token]);
const value: AuthContextValue = {
@@ -183,6 +185,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// 🔑 This is what your useApi hook imports
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
View File
+134
View File
@@ -0,0 +1,134 @@
// lib/api/adminCategoryMappings.ts
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export interface AdminCategory {
id: number;
slug: string;
name: string;
description?: string | null;
groupName?: string | null;
sortOrder?: number | null;
}
export interface AdminPartRoleMapping {
id: number;
platform: string;
partRole: string;
categorySlug: string;
groupName?: string | null;
notes?: string | null;
}
export interface CreatePartRoleMappingRequest {
platform: string;
partRole: string;
categorySlug: string;
notes?: string;
}
export interface UpdatePartRoleMappingRequest {
platform?: string;
partRole?: string;
categorySlug?: string;
notes?: string;
}
async function apiFetch<T>(
path: string,
options: {
token?: string;
method?: string;
body?: any;
} = {}
): Promise<T> {
const { token, method = "GET", body } = options;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`API error ${res.status}: ${res.statusText}${text ? ` ${text}` : ""}`
);
}
return (await res.json()) as T;
}
export async function fetchAdminCategories(token: string) {
return apiFetch<AdminCategory[]>("/api/admin/categories", { token });
}
// ---------------- Part-role mappings ----------------
const PART_ROLE_BASE = "/api/admin/part-role-mappings";
/**
* Fetch part-role/category mappings scoped by platform.
* Backend should expose: GET /api/admin/part-role-mappings?platform=AR-15
*/
export async function fetchPartRoleMappings(
token: string,
platform = "AR-15"
) {
const params = new URLSearchParams({ platform });
return apiFetch<AdminPartRoleMapping[]>(
`${PART_ROLE_BASE}?${params.toString()}`,
{ token }
);
}
/**
* Create a new part-role → category mapping
* POST /api/admin/part-role-mappings
*/
export async function createPartRoleMapping(
token: string,
payload: CreatePartRoleMappingRequest
) {
return apiFetch<AdminPartRoleMapping>(PART_ROLE_BASE, {
token,
method: "POST",
body: payload,
});
}
/**
* Update an existing part-role mapping
* PUT /api/admin/part-role-mappings/{id}
*/
export async function updatePartRoleMapping(
token: string,
id: number,
payload: UpdatePartRoleMappingRequest
) {
return apiFetch<AdminPartRoleMapping>(`${PART_ROLE_BASE}/${id}`, {
token,
method: "PUT",
body: payload,
});
}
/**
* Delete a part-role mapping
* DELETE /api/admin/part-role-mappings/{id}
*/
export async function deletePartRoleMapping(token: string, id: number) {
await apiFetch<void>(`${PART_ROLE_BASE}/${id}`, {
token,
method: "DELETE",
});
}
+60
View File
@@ -0,0 +1,60 @@
const API_BASE =
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
export interface AdminMerchantCategoryMapping {
id: number;
merchantId: number;
merchantName: string;
rawCategoryPath: string;
partCategorySlug: string | null;
}
export async function fetchMerchantCategoryMappings(
token: string,
filters?: { merchantId?: number }
): Promise<AdminMerchantCategoryMapping[]> {
const params = new URLSearchParams();
if (filters?.merchantId) {
params.append("merchantId", String(filters.merchantId));
}
const res = await fetch(
`${API_BASE}/api/admin/category-mappings?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
}
);
if (!res.ok) {
const text = await res.text();
throw new Error(`API error ${res.status}: ${text}`);
}
return res.json();
}
export async function updateMerchantCategoryMapping(
token: string,
id: number,
payload: { partCategorySlug: string | null }
): Promise<AdminMerchantCategoryMapping> {
const res = await fetch(`${API_BASE}/api/admin/category-mappings/${id}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`API error ${res.status}: ${text}`);
}
return res.json();
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useAuth } from "@/context/AuthContext";
import { useCallback, useMemo } from "react";
const DEFAULT_API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export function useApi(baseUrl: string = DEFAULT_API_BASE) {
const { token } = useAuth();
const apiFetch = useCallback(
async <T = any>(path: string, options: RequestInit = {}): Promise<T> => {
// Allow either full URLs or just `/api/...`
const url = path.startsWith("http")
? path
: `${baseUrl.replace(/\/$/, "")}${path}`;
const headers: HeadersInit = {
"Content-Type": "application/json",
...(options.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(url, {
...options,
headers,
});
if (!res.ok) {
let bodyText = "";
try {
bodyText = await res.text();
} catch {
// ignore
}
throw new Error(
`API error ${res.status}: ${
bodyText || res.statusText || "Unknown error"
}`
);
}
// handle 204 / empty body safely
try {
return (await res.json()) as T;
} catch {
return {} as T;
}
},
[baseUrl, token] // 👈 only changes when baseUrl or token change
);
const get = useCallback(
<T = any>(path: string) => apiFetch<T>(path, { method: "GET" }),
[apiFetch]
);
const post = useCallback(
<T = any>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: "POST",
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
[apiFetch]
);
const put = useCallback(
<T = any>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: "PUT",
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
[apiFetch]
);
const del = useCallback(
<T = any>(path: string) => apiFetch<T>(path, { method: "DELETE" }),
[apiFetch]
);
// Optional: memoize the returned object so destructuring doesn't change refs
return useMemo(
() => ({ apiFetch, get, post, put, del }),
[apiFetch, get, post, put, del]
);
}
+11
View File
@@ -8,6 +8,7 @@
"name": "gunbuilder-prototype",
"version": "0.1.0",
"dependencies": {
"clsx": "^2.1.1",
"next": "14.2.3",
"react": "18.3.1",
"react-dom": "18.3.1"
@@ -1610,6 +1611,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2256,6 +2266,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
+4 -3
View File
@@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.1",
"next": "14.2.3",
"react": "18.3.1",
"react-dom": "18.3.1"
@@ -18,10 +19,10 @@
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"autoprefixer": "10.4.19",
"eslint": "8.57.0",
"eslint-config-next": "14.2.3",
"postcss": "8.4.38",
"tailwindcss": "3.4.3",
"typescript": "5.4.5",
"eslint": "8.57.0",
"eslint-config-next": "14.2.3"
"typescript": "5.4.5"
}
}