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": {
"/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": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
@@ -21,15 +16,35 @@
"static/chunks/main-app.js",
"static/chunks/app/gunbuilder/page.js"
],
"/admin/gunbuilder/merchant-category-mappings/page": [
"/admin/part-role-mappings/page": [
"static/chunks/webpack.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/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/gunbuilder/part-role-mappings/page": "app/admin/gunbuilder/part-role-mappings/page.js",
"/admin/merchant-category-mappings/page": "app/admin/merchant-category-mappings/page.js",
"/gunbuilder/page": "app/gunbuilder/page.js",
"/admin/merchants/page": "app/admin/merchants/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_require__.h = () => ("37970ea9607f304f")
/******/ __webpack_require__.h = () => ("3dc90c63c330aadc")
/******/ })();
/******/
/******/ /* 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__) {
"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 */
/******/ !function() {
/******/ __webpack_require__.h = function() { return "a0aa921b182f0e3a"; }
/******/ __webpack_require__.h = function() { return "df6e8d5447f13d0a"; }
/******/ }();
/******/
/******/ /* webpack/runtime/global */
+64
View File
@@ -572,6 +572,9 @@ video {
.mt-8 {
margin-top: 2rem;
}
.mb-1\.5 {
margin-bottom: 0.375rem;
}
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
@@ -617,9 +620,15 @@ video {
.h-full {
height: 100%;
}
.h-3 {
height: 0.75rem;
}
.max-h-80 {
max-height: 20rem;
}
.max-h-56 {
max-height: 14rem;
}
.min-h-screen {
min-height: 100vh;
}
@@ -635,6 +644,18 @@ video {
.w-full {
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-width: 0px;
}
@@ -662,9 +683,24 @@ video {
.max-w-xl {
max-width: 36rem;
}
.max-w-7xl {
max-width: 80rem;
}
.flex-1 {
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 {
--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));
@@ -675,6 +711,9 @@ video {
.cursor-wait {
cursor: wait;
}
.cursor-pointer {
cursor: pointer;
}
.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;
}
@@ -766,6 +805,11 @@ video {
.overflow-y-auto {
overflow-y: auto;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.whitespace-nowrap {
white-space: nowrap;
}
@@ -957,6 +1001,9 @@ video {
.bg-zinc-950\/80 {
background-color: rgb(9 9 11 / 0.8);
}
.bg-red-700\/50 {
background-color: rgb(185 28 28 / 0.5);
}
.object-contain {
-o-object-fit: contain;
object-fit: contain;
@@ -973,6 +1020,9 @@ video {
.p-6 {
padding: 1.5rem;
}
.p-2 {
padding: 0.5rem;
}
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
@@ -1063,6 +1113,9 @@ video {
.pr-6 {
padding-right: 1.5rem;
}
.pt-3 {
padding-top: 0.75rem;
}
.text-left {
text-align: left;
}
@@ -1159,6 +1212,9 @@ video {
.tracking-wide {
letter-spacing: 0.025em;
}
.tracking-\[0\.12em\] {
letter-spacing: 0.12em;
}
.text-amber-100 {
--tw-text-opacity: 1;
color: rgb(254 243 199 / var(--tw-text-opacity));
@@ -1464,6 +1520,10 @@ body {
opacity: 0.5;
}
.disabled\:opacity-40:disabled {
opacity: 0.4;
}
.group:hover .group-hover\:visible {
visibility: visible;
}
@@ -1542,6 +1602,10 @@ body {
width: auto;
}
.md\:w-64 {
width: 16rem;
}
.md\:flex-1 {
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;
};
type AdminCategory = {
id: number;
name: string;
groupName?: string | null;
};
export default function MerchantCategoryMappingPage() {
const { get, post } = useApi();
@@ -39,6 +45,9 @@ export default function MerchantCategoryMappingPage() {
const [loadingMappings, setLoadingMappings] = useState(false);
const [savingId, setSavingId] = useState<number | 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
useEffect(() => {
@@ -62,6 +71,25 @@ export default function MerchantCategoryMappingPage() {
// 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)
useEffect(() => {
if (selectedMerchantId == null) return;
@@ -78,7 +106,9 @@ export default function MerchantCategoryMappingPage() {
.catch((err: any) => {
console.error("Failed to load category mappings", err);
setError(
err instanceof Error ? err.message : "Failed to load category mappings"
err instanceof Error
? err.message
: "Failed to load category mappings"
);
})
.finally(() => setLoadingMappings(false));
@@ -98,22 +128,75 @@ export default function MerchantCategoryMappingPage() {
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))
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);
}
};
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(
err instanceof Error ? err.message : "Failed to save mapping"
err instanceof Error
? err.message
: "Failed to save all mappings"
);
} finally {
setSavingId(null);
@@ -138,9 +221,7 @@ export default function MerchantCategoryMappingPage() {
)
}
>
{loadingMerchants && (
<option value="">Loading merchants</option>
)}
{loadingMerchants && <option value="">Loading merchants</option>}
{!loadingMerchants && merchants.length === 0 && (
<option value="">No merchants</option>
)}
@@ -159,6 +240,18 @@ export default function MerchantCategoryMappingPage() {
</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="bg-zinc-900 border-b border-zinc-800 px-4 py-2 text-xs uppercase tracking-wide text-zinc-400">
Category Mappings
@@ -192,28 +285,54 @@ export default function MerchantCategoryMappingPage() {
{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
)
);
}}
/>
{loadingCategories ? (
<span className="text-xs text-zinc-500">
Loading categories
</span>
) : categories.length === 0 ? (
<span className="text-xs text-zinc-500">
No categories
</span>
) : (
<select
className="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-sm w-64"
value={m.partCategoryId ?? ""}
onChange={(e) => {
const value = e.target.value
? 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 && (
<div className="text-xs text-zinc-500 mt-1">
{m.partCategoryName}
Current: {m.partCategoryName}
</div>
)}
</td>
@@ -238,4 +357,4 @@ export default function MerchantCategoryMappingPage() {
</div>
</div>
);
}
}
+3 -3
View File
@@ -17,19 +17,19 @@ export default function AdminHomePage() {
const cards = [
{
title: "Canonical categories",
href: "admin/gunbuilder/categories",
href: "admin/categories",
description:
"Manage the core builder categories and their group/grouping + sort order.",
},
{
title: "Part role mappings",
href: "/admin/gunbuilder/part-role-mappings",
href: "/admin/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",
href: "/admin/merchant-category-mappings",
description:
"Map raw merchant feed categories to your canonical categories.",
},
+485 -182
View File
@@ -18,6 +18,13 @@ type GunbuilderProductFromApi = {
price: number | null;
mainImageUrl: 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 =
@@ -37,14 +44,21 @@ export default function CategoryPage() {
const router = useRouter();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<Part[]>([]);
const [parts, setParts] = useState<UiPart[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// filter + sort state
const [brandFilter, setBrandFilter] = useState<string>("all");
const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance");
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
const [build, setBuild] = useState<BuildState>(() => {
@@ -91,7 +105,7 @@ export default function CategoryPage() {
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: Part[] = data.map((p) => ({
const normalized: UiPart[] = data.map((p) => ({
id: p.id,
categoryId,
name: p.name,
@@ -100,6 +114,7 @@ export default function CategoryPage() {
imageUrl: p.mainImageUrl ?? undefined,
url: p.buyUrl ?? undefined,
notes: undefined,
inStock: p.inStock ?? true,
}));
setParts(normalized);
@@ -126,6 +141,11 @@ export default function CategoryPage() {
}
}, [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
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId;
@@ -155,13 +175,65 @@ export default function CategoryPage() {
.sort((a, b) => a.localeCompare(b)),
[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(() => {
let result = [...parts];
if (brandFilter !== "all") {
result = result.filter((p) => p.brand === brandFilter);
// Price range filter
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();
@@ -203,6 +275,31 @@ export default function CategoryPage() {
return result;
}, [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) {
return (
<main className="min-h-screen bg-black text-zinc-50">
@@ -280,222 +377,428 @@ export default function CategoryPage() {
{/* Parts Grid/List */}
<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 && (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredParts.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">
{parts.length}
</span>{" "}
parts
<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>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Filters
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
Narrow down the {category.name.toLowerCase()} list.
</p>
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label htmlFor="part-search" className="text-xs text-zinc-500">
Search
</label>
<div className="relative">
<input
id="part-search"
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 && (
{/* Price range */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Price
</span>
{priceRange.min !== null || priceRange.max !== null ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 text-xs leading-none"
aria-label="Clear search"
onClick={() =>
setPriceRange({
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>
)}
</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 className="flex items-center gap-2">
<label
htmlFor="brand-filter"
className="text-xs text-zinc-500"
>
Brand
</label>
<select
id="brand-filter"
value={brandFilter}
onChange={(e) => setBrandFilter(e.target.value)}
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="all">All brands</option>
{availableBrands.map((b) => (
<option key={b} value={b}>
{b}
</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>
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<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">
In stock only
</span>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
</label>
<p className="mt-1 text-[10px] text-zinc-500">
Hides items marked out of stock by the backend.
</p>
</div>
</div>
</aside>
)}
{loading ? (
<p className="text-sm text-zinc-500 text-center py-8">
Loading {category.name.toLowerCase()}
</p>
) : error ? (
<p className="text-sm text-red-400 text-center py-8">
{error} check that the Ballistic API is running.
</p>
) : filteredParts.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-8">
No parts available for this category yet.
</p>
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{filteredParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative rounded-md p-3 transition-all duration-200 flex flex-col border ${
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 justify-between items-center gap-2 mb-3">
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "}
<span className="font-normal"> {part.name}</span>
</div>
{part.notes && (
<p className="mt-1 text-xs text-zinc-400 line-clamp-2">
{part.notes}
</p>
)}
</div>
<div className="text-sm font-semibold text-amber-300 whitespace-nowrap">
${part.price.toFixed(2)}
</div>
</div>
<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>
{/* Right side: toolbar + results */}
<div className="flex-1">
{/* Top toolbar: count + search + sort */}
{!loading && !error && parts.length > 0 && (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="font-medium text-zinc-200">
{visibleRange.start}-{visibleRange.end}
</span>{" "}
of{" "}
<span className="font-medium text-zinc-200">
{filteredParts.length}
</span>{" "}
matching parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label
htmlFor="part-search"
className="text-xs text-zinc-500"
>
Search
</label>
<div className="relative">
<input
id="part-search"
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 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
);
})}
</div>
) : (
<>
{/* Header row for list view */}
<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">
<span>Part</span>
<span>Brand</span>
<span className="text-right">Price</span>
<span className="text-right pr-2">Actions</span>
<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 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 className="space-y-2">
{filteredParts.map((part) => {
{/* Results / states */}
{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;
return (
<div
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
? "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="flex-1 min-w-0">
<div className="mb-3 flex items-center gap-2 justify-between">
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.name}
{part.brand}{" "}
<span className="font-normal">
{part.name}
</span>
</div>
{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}
</p>
)}
</div>
<div className="text-xs md:text-sm text-zinc-400 md:text-left">
{part.brand}
</div>
<div className="text-sm font-semibold text-amber-300 md:text-right">
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
<div className="flex justify-end gap-2">
<Link
href={`/gunbuilder/${categoryId}/${part.id}`}
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"
>
View Details
</Link>
<button
type="button"
onClick={() =>
handleTogglePart(categoryId, part.id)
}
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors whitespace-nowrap ${
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 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-center 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={`flex-1 rounded-md px-3 py-2 text-center 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>
</>
)}
) : (
<>
{/* 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>
</div>
</main>