new builder layout and filters. pagination
This commit is contained in:
@@ -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.
@@ -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
@@ -125,7 +125,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("37970ea9607f304f")
|
||||
/******/ __webpack_require__.h = () => ("3dc90c63c330aadc")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
|
||||
@@ -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"));
|
||||
|
||||
/***/ }),
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "a0aa921b182f0e3a"; }
|
||||
/******/ __webpack_require__.h = function() { return "df6e8d5447f13d0a"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/global */
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+155
-36
@@ -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
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user