new builder layout and filters. pagination

This commit is contained in:
2025-12-05 07:26:27 -05:00
parent cb16698940
commit 2fec870302
42 changed files with 786 additions and 238 deletions
+24 -9
View File
@@ -1,10 +1,5 @@
{ {
"pages": { "pages": {
"/admin/gunbuilder/part-role-mappings/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/admin/gunbuilder/part-role-mappings/page.js"
],
"/layout": [ "/layout": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
@@ -21,15 +16,35 @@
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/gunbuilder/page.js" "static/chunks/app/gunbuilder/page.js"
], ],
"/admin/gunbuilder/merchant-category-mappings/page": [ "/admin/part-role-mappings/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/admin/gunbuilder/merchant-category-mappings/page.js" "static/chunks/app/admin/part-role-mappings/page.js"
], ],
"/_not-found/page": [ "/admin/categories/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js" "static/chunks/app/admin/categories/page.js"
],
"/admin/merchant-category-mappings/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/admin/merchant-category-mappings/page.js"
],
"/gunbuilder/[categoryId]/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/gunbuilder/[categoryId]/page.js"
],
"/gunbuilder/[categoryId]/[partId]/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/gunbuilder/[categoryId]/[partId]/page.js"
],
"/admin/merchants/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/admin/merchants/page.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.
+4 -3
View File
@@ -1,7 +1,8 @@
{ {
"/_not-found/page": "app/_not-found/page.js", "/admin/merchant-category-mappings/page": "app/admin/merchant-category-mappings/page.js",
"/admin/gunbuilder/part-role-mappings/page": "app/admin/gunbuilder/part-role-mappings/page.js",
"/gunbuilder/page": "app/gunbuilder/page.js", "/gunbuilder/page": "app/gunbuilder/page.js",
"/admin/merchants/page": "app/admin/merchants/page.js",
"/admin/page": "app/admin/page.js", "/admin/page": "app/admin/page.js",
"/admin/gunbuilder/merchant-category-mappings/page": "app/admin/gunbuilder/merchant-category-mappings/page.js" "/gunbuilder/[categoryId]/[partId]/page": "app/gunbuilder/[categoryId]/[partId]/page.js",
"/gunbuilder/[categoryId]/page": "app/gunbuilder/[categoryId]/page.js"
} }
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
@@ -125,7 +125,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("37970ea9607f304f") /******/ __webpack_require__.h = () => ("3dc90c63c330aadc")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ /* webpack/runtime/hasOwnProperty shorthand */
+1 -1
View File
@@ -68,7 +68,7 @@ eval(__webpack_require__.ts("var __dirname = \"/\";\n(function(){var e={229:func
/***/ (function(module, __webpack_exports__, __webpack_require__) { /***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict"; "use strict";
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"aaa49504ef1d\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL2FwcC9nbG9iYWxzLmNzcyIsIm1hcHBpbmdzIjoiO0FBQUEsK0RBQWUsY0FBYztBQUM3QixJQUFJLElBQVUsSUFBSSxpQkFBaUIiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9fTl9FLy4vYXBwL2dsb2JhbHMuY3NzPzk1YWEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCJhYWE0OTUwNGVmMWRcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(app-pages-browser)/./app/globals.css\n")); eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"5b4a3f42d827\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL2FwcC9nbG9iYWxzLmNzcyIsIm1hcHBpbmdzIjoiO0FBQUEsK0RBQWUsY0FBYztBQUM3QixJQUFJLElBQVUsSUFBSSxpQkFBaUIiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9fTl9FLy4vYXBwL2dsb2JhbHMuY3NzPzk1YWEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCI1YjRhM2Y0MmQ4MjdcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(app-pages-browser)/./app/globals.css\n"));
/***/ }), /***/ }),
+1 -1
View File
@@ -192,7 +192,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ !function() { /******/ !function() {
/******/ __webpack_require__.h = function() { return "a0aa921b182f0e3a"; } /******/ __webpack_require__.h = function() { return "df6e8d5447f13d0a"; }
/******/ }(); /******/ }();
/******/ /******/
/******/ /* webpack/runtime/global */ /******/ /* webpack/runtime/global */
+64
View File
@@ -572,6 +572,9 @@ video {
.mt-8 { .mt-8 {
margin-top: 2rem; margin-top: 2rem;
} }
.mb-1\.5 {
margin-bottom: 0.375rem;
}
.line-clamp-1 { .line-clamp-1 {
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
@@ -617,9 +620,15 @@ video {
.h-full { .h-full {
height: 100%; height: 100%;
} }
.h-3 {
height: 0.75rem;
}
.max-h-80 { .max-h-80 {
max-height: 20rem; max-height: 20rem;
} }
.max-h-56 {
max-height: 14rem;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@@ -635,6 +644,18 @@ video {
.w-full { .w-full {
width: 100%; width: 100%;
} }
.w-64 {
width: 16rem;
}
.w-0 {
width: 0px;
}
.w-1\/3 {
width: 33.333333%;
}
.w-3 {
width: 0.75rem;
}
.min-w-0 { .min-w-0 {
min-width: 0px; min-width: 0px;
} }
@@ -662,9 +683,24 @@ video {
.max-w-xl { .max-w-xl {
max-width: 36rem; max-width: 36rem;
} }
.max-w-7xl {
max-width: 80rem;
}
.flex-1 { .flex-1 {
flex: 1 1 0%; flex: 1 1 0%;
} }
.shrink {
flex-shrink: 1;
}
.shrink-0 {
flex-shrink: 0;
}
.table-fixed {
table-layout: fixed;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-y-1\/2 { .-translate-y-1\/2 {
--tw-translate-y: -50%; --tw-translate-y: -50%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
@@ -675,6 +711,9 @@ video {
.cursor-wait { .cursor-wait {
cursor: wait; cursor: wait;
} }
.cursor-pointer {
cursor: pointer;
}
.grid-cols-\[minmax\(0\2c 3fr\)_minmax\(0\2c 1fr\)_minmax\(0\2c 1fr\)_auto\] { .grid-cols-\[minmax\(0\2c 3fr\)_minmax\(0\2c 1fr\)_minmax\(0\2c 1fr\)_auto\] {
grid-template-columns: minmax(0,3fr) minmax(0,1fr) minmax(0,1fr) auto; grid-template-columns: minmax(0,3fr) minmax(0,1fr) minmax(0,1fr) auto;
} }
@@ -766,6 +805,11 @@ video {
.overflow-y-auto { .overflow-y-auto {
overflow-y: auto; overflow-y: auto;
} }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.whitespace-nowrap { .whitespace-nowrap {
white-space: nowrap; white-space: nowrap;
} }
@@ -957,6 +1001,9 @@ video {
.bg-zinc-950\/80 { .bg-zinc-950\/80 {
background-color: rgb(9 9 11 / 0.8); background-color: rgb(9 9 11 / 0.8);
} }
.bg-red-700\/50 {
background-color: rgb(185 28 28 / 0.5);
}
.object-contain { .object-contain {
-o-object-fit: contain; -o-object-fit: contain;
object-fit: contain; object-fit: contain;
@@ -973,6 +1020,9 @@ video {
.p-6 { .p-6 {
padding: 1.5rem; padding: 1.5rem;
} }
.p-2 {
padding: 0.5rem;
}
.px-1 { .px-1 {
padding-left: 0.25rem; padding-left: 0.25rem;
padding-right: 0.25rem; padding-right: 0.25rem;
@@ -1063,6 +1113,9 @@ video {
.pr-6 { .pr-6 {
padding-right: 1.5rem; padding-right: 1.5rem;
} }
.pt-3 {
padding-top: 0.75rem;
}
.text-left { .text-left {
text-align: left; text-align: left;
} }
@@ -1159,6 +1212,9 @@ video {
.tracking-wide { .tracking-wide {
letter-spacing: 0.025em; letter-spacing: 0.025em;
} }
.tracking-\[0\.12em\] {
letter-spacing: 0.12em;
}
.text-amber-100 { .text-amber-100 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(254 243 199 / var(--tw-text-opacity)); color: rgb(254 243 199 / var(--tw-text-opacity));
@@ -1464,6 +1520,10 @@ body {
opacity: 0.5; opacity: 0.5;
} }
.disabled\:opacity-40:disabled {
opacity: 0.4;
}
.group:hover .group-hover\:visible { .group:hover .group-hover\:visible {
visibility: visible; visibility: visible;
} }
@@ -1542,6 +1602,10 @@ body {
width: auto; width: auto;
} }
.md\:w-64 {
width: 16rem;
}
.md\:flex-1 { .md\:flex-1 {
flex: 1 1 0%; flex: 1 1 0%;
} }
+46
View File
File diff suppressed because one or more lines are too long
@@ -27,6 +27,12 @@ type MerchantCategoryMappingDto = {
partCategoryName?: string | null; partCategoryName?: string | null;
}; };
type AdminCategory = {
id: number;
name: string;
groupName?: string | null;
};
export default function MerchantCategoryMappingPage() { export default function MerchantCategoryMappingPage() {
const { get, post } = useApi(); const { get, post } = useApi();
@@ -39,6 +45,9 @@ export default function MerchantCategoryMappingPage() {
const [loadingMappings, setLoadingMappings] = useState(false); const [loadingMappings, setLoadingMappings] = useState(false);
const [savingId, setSavingId] = useState<number | null>(null); const [savingId, setSavingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [categories, setCategories] = useState<AdminCategory[]>([]);
const [loadingCategories, setLoadingCategories] = useState(false);
const [dirty, setDirty] = useState<Record<number, CategoryMapping>>({});
// 1) Load merchants for the dropdown // 1) Load merchants for the dropdown
useEffect(() => { useEffect(() => {
@@ -62,6 +71,25 @@ export default function MerchantCategoryMappingPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Load global (canonical) part categories for the dropdown
// Load global (canonical) part categories for the dropdown
useEffect(() => {
setLoadingCategories(true);
setError(null);
get<AdminCategory[]>("/api/admin/categories")
.then((data) => {
setCategories(data);
})
.catch((err: any) => {
console.error("Failed to load categories", err);
setError(
err instanceof Error ? err.message : "Failed to load categories"
);
})
.finally(() => setLoadingCategories(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 2) Load mappings when merchant changes (THIS sends merchantId) // 2) Load mappings when merchant changes (THIS sends merchantId)
useEffect(() => { useEffect(() => {
if (selectedMerchantId == null) return; if (selectedMerchantId == null) return;
@@ -78,7 +106,9 @@ export default function MerchantCategoryMappingPage() {
.catch((err: any) => { .catch((err: any) => {
console.error("Failed to load category mappings", err); console.error("Failed to load category mappings", err);
setError( setError(
err instanceof Error ? err.message : "Failed to load category mappings" err instanceof Error
? err.message
: "Failed to load category mappings"
); );
}) })
.finally(() => setLoadingMappings(false)); .finally(() => setLoadingMappings(false));
@@ -98,22 +128,75 @@ export default function MerchantCategoryMappingPage() {
partCategoryId: updated.partCategoryId, partCategoryId: updated.partCategoryId,
} }
); );
setMappings((prev) => setMappings((prev) =>
prev.map((m) => (m.id === mappingId ? { prev.map((m) =>
...m, m.id === mappingId
// map Java DTO fields back into your UI model ? {
merchantId: updatedMapping.merchantId, ...m,
merchantName: updatedMapping.merchantName, // map Java DTO fields back into your UI model
rawCategoryPath: updatedMapping.rawCategoryPath, merchantId: updatedMapping.merchantId,
partCategoryId: updatedMapping.partCategoryId, merchantName: updatedMapping.merchantName,
partCategoryName: updatedMapping.partCategoryName, rawCategoryPath: updatedMapping.rawCategoryPath,
} : m)) partCategoryId: updatedMapping.partCategoryId,
partCategoryName: updatedMapping.partCategoryName,
}
: m
)
); );
} catch (err: any) { } catch (err: any) {
console.error("Failed to save mapping", err); console.error("Failed to save mapping", err);
setError(err instanceof Error ? err.message : "Failed to save mapping");
} finally {
setSavingId(null);
}
};
const handleSaveAll = async () => {
if (Object.keys(dirty).length === 0) return;
// special flag to indicate a global save is in progress
setSavingId(-1);
setError(null);
try {
const dirtyRows = Object.values(dirty);
const updatedMappings = await Promise.all(
dirtyRows.map((row) =>
post<MerchantCategoryMappingDto>(
`/api/admin/category-mappings/${row.id}`,
{
partCategoryId: row.partCategoryId ?? null,
}
)
)
);
setMappings((prev) =>
prev.map((m) => {
const updated = updatedMappings.find((u) => u.id === m.id);
if (!updated) return m;
return {
...m,
merchantId: updated.merchantId,
merchantName: updated.merchantName,
rawCategoryPath: updated.rawCategoryPath,
partCategoryId: updated.partCategoryId,
partCategoryName: updated.partCategoryName,
};
})
);
// Clear dirty state after successful batch save
setDirty({});
} catch (err: any) {
console.error("Failed to save all mappings", err);
setError( setError(
err instanceof Error ? err.message : "Failed to save mapping" err instanceof Error
? err.message
: "Failed to save all mappings"
); );
} finally { } finally {
setSavingId(null); setSavingId(null);
@@ -138,9 +221,7 @@ export default function MerchantCategoryMappingPage() {
) )
} }
> >
{loadingMerchants && ( {loadingMerchants && <option value="">Loading merchants</option>}
<option value="">Loading merchants</option>
)}
{!loadingMerchants && merchants.length === 0 && ( {!loadingMerchants && merchants.length === 0 && (
<option value="">No merchants</option> <option value="">No merchants</option>
)} )}
@@ -159,6 +240,18 @@ export default function MerchantCategoryMappingPage() {
</div> </div>
)} )}
{Object.keys(dirty).length > 0 && (
<div className="flex justify-end">
<button
onClick={handleSaveAll}
disabled={savingId !== null}
className="mb-3 rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
>
Save All Changes ({Object.keys(dirty).length})
</button>
</div>
)}
<div className="border border-zinc-800 rounded-lg overflow-hidden"> <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"> <div className="bg-zinc-900 border-b border-zinc-800 px-4 py-2 text-xs uppercase tracking-wide text-zinc-400">
Category Mappings Category Mappings
@@ -192,28 +285,54 @@ export default function MerchantCategoryMappingPage() {
{m.rawCategoryPath} {m.rawCategoryPath}
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{/* For now, simple text input for partCategoryId. {loadingCategories ? (
You can swap this for a select populated from /api/admin/categories */} <span className="text-xs text-zinc-500">
<input Loading categories
type="number" </span>
className="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-sm w-28" ) : categories.length === 0 ? (
value={m.partCategoryId ?? ""} <span className="text-xs text-zinc-500">
onChange={(e) => { No categories
const value = e.target.value </span>
? Number(e.target.value) ) : (
: null; <select
setMappings((prev) => className="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-sm w-64"
prev.map((row) => value={m.partCategoryId ?? ""}
row.id === m.id onChange={(e) => {
? { ...row, partCategoryId: value } const value = e.target.value
: row ? Number(e.target.value)
) : null;
);
}} const updatedRow: CategoryMapping = {
/> ...m,
partCategoryId: value,
};
setMappings((prev) =>
prev.map((row) =>
row.id === m.id ? updatedRow : row
)
);
// mark this row as dirty so it can be batch-saved
setDirty((prev) => ({
...prev,
[m.id]: updatedRow,
}));
}}
>
<option value=""> Unmapped </option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.groupName ? `[${cat.groupName}] ` : ""}
{cat.name}
</option>
))}
</select>
)}
{m.partCategoryName && ( {m.partCategoryName && (
<div className="text-xs text-zinc-500 mt-1"> <div className="text-xs text-zinc-500 mt-1">
{m.partCategoryName} Current: {m.partCategoryName}
</div> </div>
)} )}
</td> </td>
@@ -238,4 +357,4 @@ export default function MerchantCategoryMappingPage() {
</div> </div>
</div> </div>
); );
} }
+3 -3
View File
@@ -17,19 +17,19 @@ export default function AdminHomePage() {
const cards = [ const cards = [
{ {
title: "Canonical categories", title: "Canonical categories",
href: "admin/gunbuilder/categories", href: "admin/categories",
description: description:
"Manage the core builder categories and their group/grouping + sort order.", "Manage the core builder categories and their group/grouping + sort order.",
}, },
{ {
title: "Part role mappings", title: "Part role mappings",
href: "/admin/gunbuilder/part-role-mappings", href: "/admin/part-role-mappings",
description: description:
"Advanced: map logical builder part roles to canonical categories per platform.", "Advanced: map logical builder part roles to canonical categories per platform.",
}, },
{ {
title: "Merchant category mappings", title: "Merchant category mappings",
href: "/admin/gunbuilder/merchant-category-mappings", href: "/admin/merchant-category-mappings",
description: description:
"Map raw merchant feed categories to your canonical categories.", "Map raw merchant feed categories to your canonical categories.",
}, },
+485 -182
View File
@@ -18,6 +18,13 @@ type GunbuilderProductFromApi = {
price: number | null; price: number | null;
mainImageUrl: string | null; mainImageUrl: string | null;
buyUrl: string | null; buyUrl: string | null;
// optional stock flag if/when backend sends it
inStock?: boolean | null;
};
type UiPart = Part & {
// local-only stock flag for filtering
inStock?: boolean;
}; };
const API_BASE_URL = const API_BASE_URL =
@@ -37,14 +44,21 @@ export default function CategoryPage() {
const router = useRouter(); const router = useRouter();
const [viewMode, setViewMode] = useState<ViewMode>("list"); const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<Part[]>([]); const [parts, setParts] = useState<UiPart[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// filter + sort state // filter + sort state
const [brandFilter, setBrandFilter] = useState<string>("all"); const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance"); const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({
min: null,
max: null,
});
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number>(1);
const PAGE_SIZE = 24;
// build state for this page, synced with localStorage // build state for this page, synced with localStorage
const [build, setBuild] = useState<BuildState>(() => { const [build, setBuild] = useState<BuildState>(() => {
@@ -91,7 +105,7 @@ export default function CategoryPage() {
const data: GunbuilderProductFromApi[] = await res.json(); const data: GunbuilderProductFromApi[] = await res.json();
const normalized: Part[] = data.map((p) => ({ const normalized: UiPart[] = data.map((p) => ({
id: p.id, id: p.id,
categoryId, categoryId,
name: p.name, name: p.name,
@@ -100,6 +114,7 @@ export default function CategoryPage() {
imageUrl: p.mainImageUrl ?? undefined, imageUrl: p.mainImageUrl ?? undefined,
url: p.buyUrl ?? undefined, url: p.buyUrl ?? undefined,
notes: undefined, notes: undefined,
inStock: p.inStock ?? true,
})); }));
setParts(normalized); setParts(normalized);
@@ -126,6 +141,11 @@ export default function CategoryPage() {
} }
}, [build]); }, [build]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery]);
// handler to toggle Add / Remove for this category // handler to toggle Add / Remove for this category
const handleTogglePart = (categoryId: CategoryId, partId: string) => { const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId; const isSelected = build[categoryId] === partId;
@@ -155,13 +175,65 @@ export default function CategoryPage() {
.sort((a, b) => a.localeCompare(b)), .sort((a, b) => a.localeCompare(b)),
[parts], [parts],
); );
const priceBounds = useMemo(() => {
if (parts.length === 0) {
return { min: null as number | null, max: null as number | null };
}
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const p of parts) {
const price = p.price ?? 0;
if (price < min) min = price;
if (price > max) max = price;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return { min: null as number | null, max: null as number | null };
}
return { min, max };
}, [parts]);
// initialize / clamp priceRange whenever bounds change
useEffect(() => {
if (priceBounds.min == null || priceBounds.max == null) return;
setPriceRange((prev) => {
const min = prev.min ?? priceBounds.min!;
const max = prev.max ?? priceBounds.max!;
return {
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
};
});
}, [priceBounds.min, priceBounds.max]);
// filtered + sorted parts with selected part pinned to top
const filteredParts = useMemo(() => { const filteredParts = useMemo(() => {
let result = [...parts]; let result = [...parts];
if (brandFilter !== "all") { // Price range filter
result = result.filter((p) => p.brand === brandFilter); const effectiveMin =
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
const effectiveMax =
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
if (effectiveMin != null && effectiveMax != null) {
result = result.filter((p) => {
const price = p.price ?? 0;
return price >= effectiveMin && price <= effectiveMax;
});
}
// Brand filter
if (brandFilter.length > 0) {
result = result.filter((p) => brandFilter.includes(p.brand));
}
// In-stock filter
if (inStockOnly) {
result = result.filter((p) => p.inStock ?? true);
} }
const normalizedQuery = searchQuery.trim().toLowerCase(); const normalizedQuery = searchQuery.trim().toLowerCase();
@@ -203,6 +275,31 @@ export default function CategoryPage() {
return result; return result;
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery]); }, [parts, brandFilter, sortBy, build, categoryId, searchQuery]);
const totalPages = useMemo(
() => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)),
[filteredParts, PAGE_SIZE]
);
const paginatedParts = useMemo(() => {
if (filteredParts.length === 0) return [];
const startIndex = (currentPage - 1) * PAGE_SIZE;
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
}, [filteredParts, currentPage, PAGE_SIZE]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
const visibleRange = useMemo(() => {
if (filteredParts.length === 0) {
return { start: 0, end: 0 };
}
const start = (currentPage - 1) * PAGE_SIZE + 1;
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
return { start, end };
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
if (!category) { if (!category) {
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
@@ -280,222 +377,428 @@ export default function CategoryPage() {
{/* Parts Grid/List */} {/* Parts Grid/List */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4"> <section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
{/* Filter + sort toolbar */} <div className="flex flex-col gap-4 md:flex-row">
{/* Left sidebar: filters */}
{!loading && !error && parts.length > 0 && ( {!loading && !error && parts.length > 0 && (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
<div className="text-xs text-zinc-500"> <div>
Showing{" "} <h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
<span className="text-zinc-200 font-medium"> Filters
{filteredParts.length} </h2>
</span>{" "} <p className="mt-1 text-[11px] text-zinc-500">
of{" "} Narrow down the {category.name.toLowerCase()} list.
<span className="text-zinc-200 font-medium"> </p>
{parts.length}
</span>{" "}
parts
</div> </div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2"> {/* Price range */}
<label htmlFor="part-search" className="text-xs text-zinc-500"> <div>
Search <div className="mb-1.5 flex items-center justify-between">
</label> <span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
<div className="relative"> Price
<input </span>
id="part-search" {priceRange.min !== null || priceRange.max !== null ? (
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search ${category.name.toLowerCase()}...`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-200 px-2 py-1 pr-6 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button <button
type="button" type="button"
onClick={() => setSearchQuery("")} onClick={() =>
className="absolute right-1 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 text-xs leading-none" setPriceRange({
aria-label="Clear search" min: priceBounds.min,
max: priceBounds.max,
})
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
> >
× Reset
</button>
) : null}
</div>
{priceBounds.min == null || priceBounds.max == null ? (
<p className="text-[11px] text-zinc-500">
No price data available.
</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400">
<span>
Min:{" "}
<span className="font-semibold text-zinc-200">
$
{(priceRange.min ?? priceBounds.min).toFixed(0)}
</span>
</span>
<span>
Max:{" "}
<span className="font-semibold text-zinc-200">
$
{(priceRange.max ?? priceBounds.max).toFixed(0)}
</span>
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.min ?? priceBounds.min ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: Math.min(
value,
prev.max ?? priceBounds.max ?? value
),
max:
prev.max ??
priceBounds.max ??
value,
}));
}}
className="w-full"
/>
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.max ?? priceBounds.max ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min:
prev.min ??
priceBounds.min ??
value,
max: Math.max(
value,
prev.min ?? priceBounds.min ?? value
),
}));
}}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Brand multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand
</span>
{brandFilter.length > 0 && (
<button
type="button"
onClick={() => setBrandFilter([])}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Clear
</button> </button>
)} )}
</div> </div>
{availableBrands.length === 0 ? (
<p className="text-[11px] text-zinc-500">
No brands available yet.
</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{availableBrands.map((b) => {
const checked = brandFilter.includes(b);
return (
<label
key={b}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={checked}
onChange={(e) => {
setBrandFilter((prev) =>
e.target.checked
? [...prev, b]
: prev.filter((brand) => brand !== b)
);
}}
/>
<span className="truncate">{b}</span>
</label>
);
})}
</div>
)}
{brandFilter.length === 0 && availableBrands.length > 0 && (
<p className="mt-1 text-[10px] text-zinc-500">
No brands selected showing all.
</p>
)}
</div> </div>
<div className="flex items-center gap-2">
<label {/* In-stock toggle */}
htmlFor="brand-filter" <div className="border-t border-zinc-800 pt-3">
className="text-xs text-zinc-500" <label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
> <span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand In stock only
</label> </span>
<select <input
id="brand-filter" type="checkbox"
value={brandFilter} className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
onChange={(e) => setBrandFilter(e.target.value)} checked={inStockOnly}
className="rounded-md border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-200 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-amber-400" onChange={(e) => setInStockOnly(e.target.checked)}
> />
<option value="all">All brands</option> </label>
{availableBrands.map((b) => ( <p className="mt-1 text-[10px] text-zinc-500">
<option key={b} value={b}> Hides items marked out of stock by the backend.
{b} </p>
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label htmlFor="sort-by" className="text-xs text-zinc-500">
Sort
</label>
<select
id="sort-by"
value={sortBy}
onChange={(e) =>
setSortBy(e.target.value as SortOption)
}
className="rounded-md border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-200 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div> </div>
</div> </aside>
)} )}
{loading ? ( {/* Right side: toolbar + results */}
<p className="text-sm text-zinc-500 text-center py-8"> <div className="flex-1">
Loading {category.name.toLowerCase()} {/* Top toolbar: count + search + sort */}
</p> {!loading && !error && parts.length > 0 && (
) : error ? ( <div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-red-400 text-center py-8"> <div className="text-xs text-zinc-500">
{error} check that the Ballistic API is running. Showing{" "}
</p> <span className="font-medium text-zinc-200">
) : filteredParts.length === 0 ? ( {visibleRange.start}-{visibleRange.end}
<p className="text-sm text-zinc-500 text-center py-8"> </span>{" "}
No parts available for this category yet. of{" "}
</p> <span className="font-medium text-zinc-200">
) : viewMode === "card" ? ( {filteredParts.length}
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> </span>{" "}
{filteredParts.map((part) => { matching parts
const isSelected = build[categoryId] === part.id; </div>
<div className="flex flex-wrap gap-3">
return ( <div className="flex items-center gap-2">
<div <label
key={part.id} htmlFor="part-search"
className={`group relative rounded-md p-3 transition-all duration-200 flex flex-col border ${ className="text-xs text-zinc-500"
isSelected >
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]" Search
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10" </label>
}`} <div className="relative">
> <input
<div className="flex justify-between items-center gap-2 mb-3"> id="part-search"
<div className="flex-1"> type="text"
<div className="text-sm font-semibold text-zinc-50"> value={searchQuery}
{part.brand}{" "} onChange={(e) => setSearchQuery(e.target.value)}
<span className="font-normal"> {part.name}</span> placeholder={`Search ${category.name.toLowerCase()}...`}
</div> className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
{part.notes && ( />
<p className="mt-1 text-xs text-zinc-400 line-clamp-2"> {searchQuery && (
{part.notes} <button
</p> type="button"
)} onClick={() => setSearchQuery("")}
</div> className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
<div className="text-sm font-semibold text-amber-300 whitespace-nowrap"> aria-label="Clear search"
${part.price.toFixed(2)} >
</div> ×
</div> </button>
<div className="mt-2 flex gap-2"> )}
<Link
href={`/gunbuilder/${categoryId}/${part.id}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-700 hover:border-zinc-600 transition-colors text-center"
>
View Details
</Link>
<button
type="button"
onClick={() => handleTogglePart(categoryId, part.id)}
className={`flex-1 rounded-md px-3 py-2 text-xs font-semibold transition-colors text-center ${
isSelected
? "bg-zinc-800 text-zinc-100 border border-zinc-600 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div> </div>
</div> </div>
); <div className="flex items-center gap-2">
})} <label
</div> htmlFor="sort-by"
) : ( className="text-xs text-zinc-500"
<> >
{/* Header row for list view */} Sort
<div className="hidden md:grid grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500"> </label>
<span>Part</span> <select
<span>Brand</span> id="sort-by"
<span className="text-right">Price</span> value={sortBy}
<span className="text-right pr-2">Actions</span> onChange={(e) =>
setSortBy(e.target.value as SortOption)
}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div>
</div> </div>
)}
<div className="space-y-2"> {/* Results / states */}
{filteredParts.map((part) => { {loading ? (
<p className="py-8 text-center text-sm text-zinc-500">
Loading {category.name.toLowerCase()}
</p>
) : error ? (
<p className="py-8 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500">
No parts available for this category yet.
</p>
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id; const isSelected = build[categoryId] === part.id;
return ( return (
<div <div
key={part.id} key={part.id}
className={`group relative rounded-md p-3 transition-all duration-200 border ${ className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
isSelected isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]" ? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10" : "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`} }`}
> >
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4"> <div className="mb-3 flex items-center gap-2 justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1">
<div className="text-sm font-semibold text-zinc-50"> <div className="text-sm font-semibold text-zinc-50">
{part.name} {part.brand}{" "}
<span className="font-normal">
{part.name}
</span>
</div> </div>
{part.notes && ( {part.notes && (
<p className="mt-1 text-xs text-zinc-400 line-clamp-1"> <p className="mt-1 line-clamp-2 text-xs text-zinc-400">
{part.notes} {part.notes}
</p> </p>
)} )}
</div> </div>
<div className="text-xs md:text-sm text-zinc-400 md:text-left"> <div className="whitespace-nowrap text-sm font-semibold text-amber-300">
{part.brand}
</div>
<div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)} ${part.price.toFixed(2)}
</div> </div>
<div className="flex justify-end gap-2"> </div>
<Link <div className="mt-2 flex gap-2">
href={`/gunbuilder/${categoryId}/${part.id}`} <Link
className="rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-700 hover:border-zinc-600 transition-colors whitespace-nowrap" href={`/gunbuilder/${categoryId}/${part.id}`}
> className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
View Details >
</Link> View Details
<button </Link>
type="button" <button
onClick={() => type="button"
handleTogglePart(categoryId, part.id) onClick={() => handleTogglePart(categoryId, part.id)}
} className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors whitespace-nowrap ${ isSelected
isSelected ? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
? "bg-zinc-800 text-zinc-100 border border-zinc-600 hover:bg-zinc-700" : "bg-amber-400 text-black hover:bg-amber-300"
: "bg-amber-400 text-black hover:bg-amber-300" }`}
}`} >
> {isSelected ? "Remove from Build" : "Add to Build"}
{isSelected ? "Remove from Build" : "Add to Build"} </button>
</button>
</div>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
</> ) : (
)} <>
{/* Header row for list view */}
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
<span>Part</span>
<span>Brand</span>
<span className="text-right">Price</span>
<span className="pr-2 text-right">Actions</span>
</div>
<div className="space-y-2">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.name}
</div>
{part.notes && (
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
{part.brand}
</div>
<div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)}
</div>
<div className="flex justify-end gap-2">
<Link
href={`/gunbuilder/${categoryId}/${part.id}`}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() =>
handleTogglePart(categoryId, part.id)
}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
</div>
);
})}
</div>
</>
)}
{/* Pagination controls */}
{filteredParts.length > 0 && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
<div>
Page{" "}
<span className="font-semibold text-zinc-200">
{currentPage}
</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">
{totalPages}
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 1))
}
disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
disabled={currentPage === totalPages}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
</section> </section>
</div> </div>
</main> </main>