pulling products from spring api. live data
This commit is contained in:
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"pages": {
|
||||
"/gunbuilder/page": [
|
||||
"/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/gunbuilder/page.js"
|
||||
"static/chunks/app/page.js"
|
||||
],
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
@@ -11,15 +11,20 @@
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
],
|
||||
"/gunbuilder/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/gunbuilder/page.js"
|
||||
],
|
||||
"/gunbuilder/[categoryId]/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/gunbuilder/[categoryId]/page.js"
|
||||
],
|
||||
"/gunbuilder/build/page": [
|
||||
"/gunbuilder/[categoryId]/[partId]/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/gunbuilder/build/page.js"
|
||||
"static/chunks/app/gunbuilder/[categoryId]/[partId]/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,5 +1,5 @@
|
||||
{
|
||||
"/gunbuilder/[categoryId]/page": "app/gunbuilder/[categoryId]/page.js",
|
||||
"/gunbuilder/page": "app/gunbuilder/page.js",
|
||||
"/gunbuilder/build/page": "app/gunbuilder/build/page.js"
|
||||
"/gunbuilder/[categoryId]/[partId]/page": "app/gunbuilder/[categoryId]/[partId]/page.js"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"ZxASFi7MiqyHozChjYSoQefUKtZDWURXFCvYTejSOjs=\"\n}"
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"PyCIl678GNAha8RPGKgUOQBg4GVuvLwB0fMPvLGLebg=\"\n}"
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "ZxASFi7MiqyHozChjYSoQefUKtZDWURXFCvYTejSOjs="
|
||||
"encryptionKey": "PyCIl678GNAha8RPGKgUOQBg4GVuvLwB0fMPvLGLebg="
|
||||
}
|
||||
@@ -125,7 +125,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("cacb2d11823559d2")
|
||||
/******/ __webpack_require__.h = () => ("d594e5cfece503fe")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
|
||||
+33
-12
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -25,7 +25,7 @@ eval(__webpack_require__.ts("Promise.resolve(/*! import() eager */).then(__webpa
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"79151dd11033\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL2FwcC9nbG9iYWxzLmNzcyIsIm1hcHBpbmdzIjoiO0FBQUEsK0RBQWUsY0FBYztBQUM3QixJQUFJLElBQVUsSUFBSSxpQkFBaUIiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9fTl9FLy4vYXBwL2dsb2JhbHMuY3NzPzk1YWEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCI3OTE1MWRkMTEwMzNcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\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\"] = (\"7e7c27b471d2\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL2FwcC9nbG9iYWxzLmNzcyIsIm1hcHBpbmdzIjoiO0FBQUEsK0RBQWUsY0FBYztBQUM3QixJQUFJLElBQVUsSUFBSSxpQkFBaUIiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9fTl9FLy4vYXBwL2dsb2JhbHMuY3NzPzk1YWEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCI3ZTdjMjdiNDcxZDJcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(app-pages-browser)/./app/globals.css\n"));
|
||||
|
||||
/***/ })
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -192,7 +192,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "95f881488f2e5382"; }
|
||||
/******/ __webpack_require__.h = function() { return "f608a9ecbacea728"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/global */
|
||||
|
||||
+123
-75
@@ -520,6 +520,10 @@ video {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.mx-1 {
|
||||
margin-left: 0.25rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -586,14 +590,17 @@ video {
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
.h-full {
|
||||
height: 100%;
|
||||
.h-32 {
|
||||
height: 8rem;
|
||||
}
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
.h-32 {
|
||||
height: 8rem;
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
.max-h-80 {
|
||||
max-height: 20rem;
|
||||
}
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
@@ -601,15 +608,18 @@ video {
|
||||
.w-16 {
|
||||
width: 4rem;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.min-w-0 {
|
||||
min-width: 0px;
|
||||
}
|
||||
.max-w-4xl {
|
||||
max-width: 56rem;
|
||||
}
|
||||
.max-w-6xl {
|
||||
max-width: 72rem;
|
||||
}
|
||||
@@ -619,8 +629,8 @@ video {
|
||||
.max-w-xl {
|
||||
max-width: 36rem;
|
||||
}
|
||||
.max-w-4xl {
|
||||
max-width: 56rem;
|
||||
.max-w-5xl {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
@@ -670,6 +680,9 @@ video {
|
||||
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -698,12 +711,22 @@ video {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(251 191 36 / var(--tw-border-opacity));
|
||||
}
|
||||
.border-amber-400\/30 {
|
||||
border-color: rgb(251 191 36 / 0.3);
|
||||
}
|
||||
.border-amber-400\/60 {
|
||||
border-color: rgb(251 191 36 / 0.6);
|
||||
}
|
||||
.border-amber-400\/70 {
|
||||
border-color: rgb(251 191 36 / 0.7);
|
||||
}
|
||||
.border-green-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
||||
}
|
||||
.border-red-500\/30 {
|
||||
border-color: rgb(239 68 68 / 0.3);
|
||||
}
|
||||
.border-zinc-700 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(63 63 70 / var(--tw-border-opacity));
|
||||
@@ -712,23 +735,22 @@ video {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(39 39 42 / var(--tw-border-opacity));
|
||||
}
|
||||
.border-green-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
||||
}
|
||||
.border-amber-400\/30 {
|
||||
border-color: rgb(251 191 36 / 0.3);
|
||||
}
|
||||
.border-red-500\/30 {
|
||||
border-color: rgb(239 68 68 / 0.3);
|
||||
}
|
||||
.bg-amber-400\/10 {
|
||||
background-color: rgb(251 191 36 / 0.1);
|
||||
}
|
||||
.bg-amber-400\/20 {
|
||||
background-color: rgb(251 191 36 / 0.2);
|
||||
}
|
||||
.bg-black {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
||||
}
|
||||
.bg-green-500\/10 {
|
||||
background-color: rgb(34 197 94 / 0.1);
|
||||
}
|
||||
.bg-red-500\/20 {
|
||||
background-color: rgb(239 68 68 / 0.2);
|
||||
}
|
||||
.bg-zinc-800 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(39 39 42 / var(--tw-bg-opacity));
|
||||
@@ -740,6 +762,9 @@ video {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(24 24 27 / var(--tw-bg-opacity));
|
||||
}
|
||||
.bg-zinc-900\/20 {
|
||||
background-color: rgb(24 24 27 / 0.2);
|
||||
}
|
||||
.bg-zinc-900\/30 {
|
||||
background-color: rgb(24 24 27 / 0.3);
|
||||
}
|
||||
@@ -752,17 +777,23 @@ video {
|
||||
.bg-zinc-950\/80 {
|
||||
background-color: rgb(9 9 11 / 0.8);
|
||||
}
|
||||
.bg-green-500\/10 {
|
||||
background-color: rgb(34 197 94 / 0.1);
|
||||
.bg-amber-400 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(251 191 36 / var(--tw-bg-opacity));
|
||||
}
|
||||
.bg-amber-400\/20 {
|
||||
background-color: rgb(251 191 36 / 0.2);
|
||||
.bg-zinc-900\/60 {
|
||||
background-color: rgb(24 24 27 / 0.6);
|
||||
}
|
||||
.bg-red-500\/20 {
|
||||
background-color: rgb(239 68 68 / 0.2);
|
||||
.bg-zinc-950 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(9 9 11 / var(--tw-bg-opacity));
|
||||
}
|
||||
.bg-zinc-900\/20 {
|
||||
background-color: rgb(24 24 27 / 0.2);
|
||||
.bg-zinc-950\/70 {
|
||||
background-color: rgb(9 9 11 / 0.7);
|
||||
}
|
||||
.object-contain {
|
||||
-o-object-fit: contain;
|
||||
object-fit: contain;
|
||||
}
|
||||
.p-1 {
|
||||
padding: 0.25rem;
|
||||
@@ -779,6 +810,14 @@ video {
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
.px-1 {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.px-1\.5 {
|
||||
padding-left: 0.375rem;
|
||||
padding-right: 0.375rem;
|
||||
}
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
@@ -787,6 +826,18 @@ video {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
.px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.py-0 {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
@@ -803,6 +854,10 @@ video {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.py-6 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
@@ -811,29 +866,9 @@ video {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
.px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.px-1 {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.px-1\.5 {
|
||||
padding-left: 0.375rem;
|
||||
padding-right: 0.375rem;
|
||||
}
|
||||
.py-0 {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
.py-2\.5 {
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
.pr-1 {
|
||||
padding-right: 0.25rem;
|
||||
@@ -861,6 +896,9 @@ video {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
.text-\[0\.65rem\] {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.text-\[0\.7rem\] {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
@@ -876,8 +914,9 @@ video {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
.text-\[0\.65rem\] {
|
||||
font-size: 0.65rem;
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
@@ -917,6 +956,14 @@ video {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(252 211 77 / var(--tw-text-opacity));
|
||||
}
|
||||
.text-green-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(74 222 128 / var(--tw-text-opacity));
|
||||
}
|
||||
.text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
.text-zinc-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(212 212 216 / var(--tw-text-opacity));
|
||||
@@ -937,13 +984,13 @@ video {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(82 82 91 / var(--tw-text-opacity));
|
||||
}
|
||||
.text-green-400 {
|
||||
.text-black {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(74 222 128 / var(--tw-text-opacity));
|
||||
color: rgb(0 0 0 / var(--tw-text-opacity));
|
||||
}
|
||||
.text-red-400 {
|
||||
.text-zinc-200 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
color: rgb(228 228 231 / var(--tw-text-opacity));
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
@@ -951,12 +998,12 @@ video {
|
||||
.line-through {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
.opacity-40 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
.opacity-40 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.opacity-60 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -968,13 +1015,13 @@ video {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
@@ -994,6 +1041,10 @@ body {
|
||||
color: rgb(250 250 250 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:border-amber-400\/60:hover {
|
||||
border-color: rgb(251 191 36 / 0.6);
|
||||
}
|
||||
|
||||
.hover\:border-zinc-500:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(113 113 122 / var(--tw-border-opacity));
|
||||
@@ -1004,8 +1055,8 @@ body {
|
||||
border-color: rgb(82 82 91 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.hover\:border-amber-400\/60:hover {
|
||||
border-color: rgb(251 191 36 / 0.6);
|
||||
.hover\:bg-amber-400\/10:hover {
|
||||
background-color: rgb(251 191 36 / 0.1);
|
||||
}
|
||||
|
||||
.hover\:bg-amber-400\/15:hover {
|
||||
@@ -1022,8 +1073,9 @@ body {
|
||||
background-color: rgb(39 39 42 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-amber-400\/10:hover {
|
||||
background-color: rgb(251 191 36 / 0.1);
|
||||
.hover\:bg-amber-300:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(252 211 77 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-amber-200:hover {
|
||||
@@ -1036,14 +1088,6 @@ body {
|
||||
color: rgb(212 212 216 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.disabled\:cursor-not-allowed:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabled\:opacity-40:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:opacity-100 {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1077,6 +1121,10 @@ body {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.md\:grid-cols-\[minmax\(0\2c 2fr\)_minmax\(0\2c 1\.3fr\)\] {
|
||||
grid-template-columns: minmax(0,2fr) minmax(0,1.3fr);
|
||||
}
|
||||
|
||||
.md\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"c":["app/layout","app/gunbuilder/[categoryId]/[partId]/page","webpack"],"r":[],"m":["(app-pages-browser)/./app/gunbuilder/[categoryId]/[partId]/data.ts","(app-pages-browser)/./components/PricingHistoryGraph.tsx","(app-pages-browser)/./components/RetailersList.tsx"]}
|
||||
@@ -0,0 +1 @@
|
||||
{"c":["app/layout","app/gunbuilder/page","webpack"],"r":[],"m":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"c":["app/layout","app/gunbuilder/[categoryId]/page","webpack"],"r":[],"m":[]}
|
||||
+43
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("app/layout",{
|
||||
|
||||
/***/ "(app-pages-browser)/./app/globals.css":
|
||||
/*!*************************!*\
|
||||
!*** ./app/globals.css ***!
|
||||
\*************************/
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"7e7c27b471d2\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL2FwcC9nbG9iYWxzLmNzcyIsIm1hcHBpbmdzIjoiO0FBQUEsK0RBQWUsY0FBYztBQUM3QixJQUFJLElBQVUsSUFBSSxpQkFBaUIiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9fTl9FLy4vYXBwL2dsb2JhbHMuY3NzPzk1YWEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCI3ZTdjMjdiNDcxZDJcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(app-pages-browser)/./app/globals.css\n"));
|
||||
|
||||
/***/ })
|
||||
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("app/layout",{
|
||||
|
||||
/***/ "(app-pages-browser)/./app/globals.css":
|
||||
/*!*************************!*\
|
||||
!*** ./app/globals.css ***!
|
||||
\*************************/
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"009cc7eaae1c\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL2FwcC9nbG9iYWxzLmNzcyIsIm1hcHBpbmdzIjoiO0FBQUEsK0RBQWUsY0FBYztBQUM3QixJQUFJLElBQVUsSUFBSSxpQkFBaUIiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9fTl9FLy4vYXBwL2dsb2JhbHMuY3NzPzk1YWEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCIwMDljYzdlYWFlMWNcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(app-pages-browser)/./app/globals.css\n"));
|
||||
|
||||
/***/ })
|
||||
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("app/layout",{
|
||||
|
||||
/***/ "(app-pages-browser)/./app/globals.css":
|
||||
/*!*************************!*\
|
||||
!*** ./app/globals.css ***!
|
||||
\*************************/
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"98118a763a8b\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL2FwcC9nbG9iYWxzLmNzcyIsIm1hcHBpbmdzIjoiO0FBQUEsK0RBQWUsY0FBYztBQUM3QixJQUFJLElBQVUsSUFBSSxpQkFBaUIiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9fTl9FLy4vYXBwL2dsb2JhbHMuY3NzPzk1YWEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCI5ODExOGE3NjNhOGJcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(app-pages-browser)/./app/globals.css\n"));
|
||||
|
||||
/***/ })
|
||||
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{"c":["webpack"],"r":[],"m":[]}
|
||||
+1
-1
@@ -11,7 +11,7 @@ self["webpackHotUpdate_N_E"]("webpack",{},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "9d51351cbbe0e509"; }
|
||||
/******/ __webpack_require__.h = function() { return "f608a9ecbacea728"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ }
|
||||
+1
-1
@@ -11,7 +11,7 @@ self["webpackHotUpdate_N_E"]("webpack",{},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "95f881488f2e5382"; }
|
||||
/******/ __webpack_require__.h = function() { return "7a4dc9caf2cd02f5"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ }
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("webpack",{},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "684e6e031859effc"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ }
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("webpack",{},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "f13210145343ce93"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ }
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("webpack",{},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ /* webpack/runtime/compat get default export */
|
||||
/******/ !function() {
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = function(module) {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ function() { return module['default']; } :
|
||||
/******/ function() { return module; };
|
||||
/******/ __webpack_require__.d(getter, { a: getter });
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "6cfb75c613171e61"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ }
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("webpack",{},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "4c869a7dd1d964e6"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ }
|
||||
);
|
||||
+10
-5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
// File: /Users/sean/Dev/gunbuilder-prototype/app/gunbuilder/[categoryId]/[partId]/page.tsx
|
||||
import * as entry from '../../../../../../app/gunbuilder/[categoryId]/[partId]/page.js'
|
||||
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
|
||||
|
||||
type TEntry = typeof import('../../../../../../app/gunbuilder/[categoryId]/[partId]/page.js')
|
||||
|
||||
// Check that the entry is a valid entry
|
||||
checkFields<Diff<{
|
||||
default: Function
|
||||
config?: {}
|
||||
generateStaticParams?: Function
|
||||
revalidate?: RevalidateRange<TEntry> | false
|
||||
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
|
||||
dynamicParams?: boolean
|
||||
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
|
||||
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
|
||||
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
|
||||
maxDuration?: number
|
||||
|
||||
metadata?: any
|
||||
generateMetadata?: Function
|
||||
viewport?: any
|
||||
generateViewport?: Function
|
||||
|
||||
}, TEntry, ''>>()
|
||||
|
||||
// Check the prop type of the entry function
|
||||
checkFields<Diff<PageProps, FirstArg<TEntry['default']>, 'default'>>()
|
||||
|
||||
// Check the arguments and return type of the generateMetadata function
|
||||
if ('generateMetadata' in entry) {
|
||||
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
||||
checkFields<Diff<ResolvingMetadata, SecondArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
||||
}
|
||||
|
||||
// Check the arguments and return type of the generateViewport function
|
||||
if ('generateViewport' in entry) {
|
||||
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
||||
checkFields<Diff<ResolvingViewport, SecondArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
||||
}
|
||||
|
||||
// Check the arguments and return type of the generateStaticParams function
|
||||
if ('generateStaticParams' in entry) {
|
||||
checkFields<Diff<{ params: PageParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
|
||||
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
|
||||
}
|
||||
|
||||
type PageParams = any
|
||||
export interface PageProps {
|
||||
params?: any
|
||||
searchParams?: any
|
||||
}
|
||||
export interface LayoutProps {
|
||||
children?: React.ReactNode
|
||||
|
||||
params?: any
|
||||
}
|
||||
|
||||
// =============
|
||||
// Utility types
|
||||
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
|
||||
|
||||
// If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
|
||||
type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
|
||||
type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
|
||||
|
||||
type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
|
||||
type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
|
||||
type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
|
||||
|
||||
|
||||
|
||||
function checkFields<_ extends { [k in keyof any]: never }>() {}
|
||||
|
||||
// https://github.com/sindresorhus/type-fest
|
||||
type Numeric = number | bigint
|
||||
type Zero = 0 | 0n
|
||||
type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
|
||||
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'
|
||||
@@ -1,8 +1,8 @@
|
||||
// File: /Users/sean/Dev/gunbuilder-prototype/app/gunbuilder/build/page.tsx
|
||||
import * as entry from '../../../../../app/gunbuilder/build/page.js'
|
||||
// File: /Users/sean/Dev/gunbuilder-prototype/app/page.tsx
|
||||
import * as entry from '../../../app/page.js'
|
||||
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
|
||||
|
||||
type TEntry = typeof import('../../../../../app/gunbuilder/build/page.js')
|
||||
type TEntry = typeof import('../../../app/page.js')
|
||||
|
||||
// Check that the entry is a valid entry
|
||||
checkFields<Diff<{
|
||||
@@ -1,59 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CATEGORIES, PARTS } from "@/data/gunbuilderParts";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
import { getPricingHistory, getRetailerOffers } from "./data";
|
||||
import { PricingHistoryGraph } from "@/components/PricingHistoryGraph";
|
||||
import { RetailersList } from "@/components/RetailersList";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
brandName: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
mainImageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export default function PartDetailPage() {
|
||||
const params = useParams();
|
||||
const categoryId = params.categoryId as CategoryId;
|
||||
const partId = params.partId as string;
|
||||
const partId = params.partId as string; // this is the UUID we passed in the link
|
||||
|
||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const category = useMemo(
|
||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
||||
[categoryId],
|
||||
);
|
||||
|
||||
const part = useMemo(
|
||||
() => PARTS.find((p) => p.id === partId && p.categoryId === categoryId),
|
||||
[partId, categoryId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!partId) return;
|
||||
|
||||
// Get pricing history and retailer offers
|
||||
// TODO: Replace with API calls when endpoints are ready
|
||||
const pricingHistory = useMemo(
|
||||
() => (part ? getPricingHistory(part.id, part.price) : []),
|
||||
[part],
|
||||
);
|
||||
const controller = new AbortController();
|
||||
|
||||
const retailerOffers = useMemo(
|
||||
() =>
|
||||
part
|
||||
? getRetailerOffers(part.id, part.price, part.affiliateUrl)
|
||||
: [],
|
||||
[part],
|
||||
);
|
||||
async function fetchProduct() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!category || !part) {
|
||||
// For now, pull the full AR-15 list and find by UUID.
|
||||
// We can optimize with a dedicated /api/products/gunbuilder/{uuid} later.
|
||||
const url = `${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load product (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
const found = data.find((p) => p.uuid === partId) ?? null;
|
||||
|
||||
if (!found) {
|
||||
setError("Product not found");
|
||||
}
|
||||
|
||||
setProduct(found);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") return;
|
||||
setError(err.message ?? "Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [partId]);
|
||||
|
||||
if (!category) {
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
||||
Product Not Found
|
||||
</h1>
|
||||
<Link
|
||||
href="/gunbuilder"
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Return to Gunbuilder
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
|
||||
<Link
|
||||
href="/gunbuilder"
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Return to Gunbuilder
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@@ -61,152 +94,123 @@ export default function PartDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
{/* Header */}
|
||||
<header className="mb-6">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="mb-4 text-xs text-zinc-500">
|
||||
<Link
|
||||
href="/gunbuilder"
|
||||
className="hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Gunbuilder
|
||||
</Link>
|
||||
<span className="mx-1">/</span>
|
||||
<Link
|
||||
href={`/gunbuilder/${categoryId}`}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
||||
className="hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
← Back to {category.name}
|
||||
{category.name}
|
||||
</Link>
|
||||
{product && (
|
||||
<>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-zinc-300">{product.name}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-zinc-500">Loading product…</p>
|
||||
) : error || !product ? (
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Shadow Standard
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{part.brand} <span className="text-amber-300">{part.name}</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||
{category.name} • Product Details
|
||||
<h1 className="text-xl font-semibold mb-2">Product Unavailable</h1>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
{error ?? "We couldn’t find this product."}
|
||||
</p>
|
||||
<Link
|
||||
href={`/gunbuilder/${categoryId}`}
|
||||
className="text-amber-300 hover:text-amber-200 underline text-sm"
|
||||
>
|
||||
Back to {category.name} parts
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="grid gap-4 lg:grid-cols-[2fr,1fr]">
|
||||
{/* Main Content */}
|
||||
<section className="space-y-4">
|
||||
{/* Product Information */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||
<div className="grid gap-6 md:grid-cols-[1fr,1fr]">
|
||||
{/* Product Info */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400 mb-2">
|
||||
Product Information
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wide mb-1">
|
||||
Brand
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-zinc-50">
|
||||
{part.brand}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wide mb-1">
|
||||
Model
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-zinc-50">
|
||||
{part.name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wide mb-1">
|
||||
Category
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-zinc-50">
|
||||
{category.name}
|
||||
</div>
|
||||
</div>
|
||||
{part.notes && (
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wide mb-1">
|
||||
Details
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300 leading-relaxed">
|
||||
{part.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Image */}
|
||||
<div className="flex items-start justify-center">
|
||||
<div className="w-full aspect-square max-w-md rounded-md border border-zinc-700 bg-zinc-900/50 flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto text-zinc-600 mb-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wide">
|
||||
Product Image
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,2fr)_minmax(0,1.3fr)] items-start">
|
||||
{/* Left: image + meta */}
|
||||
<div className="space-y-4">
|
||||
{product.mainImageUrl && (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={product.mainImageUrl}
|
||||
alt={product.name}
|
||||
className="w-full object-contain max-h-80 bg-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
{product.brandName}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Platform: <span className="text-zinc-300">{product.platform}</span>
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Role: <span className="text-zinc-300">{product.partRole}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Retailers List */}
|
||||
{retailerOffers.length > 0 && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||
<RetailersList retailers={retailerOffers} />
|
||||
{/* Right: pricing + actions */}
|
||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-zinc-500 mb-1">
|
||||
Price
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-amber-300">
|
||||
{product.price && product.price > 0
|
||||
? `$${product.price.toFixed(2)}`
|
||||
: "—"}
|
||||
</p>
|
||||
{!product.price && (
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Pricing not available yet. Live pricing will appear once
|
||||
offers are imported.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-950/80 p-4 md:p-6 flex flex-col">
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-zinc-400 mb-2">Price</div>
|
||||
<div className="text-3xl font-semibold text-amber-300">
|
||||
${part.price.toFixed(2)}
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href={`/gunbuilder?select=${categoryId}:${product.uuid}`}
|
||||
className="block w-full rounded-md bg-amber-400 text-black text-sm font-semibold text-center py-2.5 hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Add to Build
|
||||
</Link>
|
||||
|
||||
{product.buyUrl ? (
|
||||
<a
|
||||
href={product.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block w-full rounded-md border border-zinc-700 bg-zinc-900 text-xs font-medium text-zinc-200 text-center py-2 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
View on Merchant Site
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="block w-full rounded-md border border-zinc-800 bg-zinc-900/60 text-xs font-medium text-zinc-500 text-center py-2 cursor-not-allowed"
|
||||
>
|
||||
External link coming soon
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing History */}
|
||||
{pricingHistory.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<PricingHistoryGraph
|
||||
data={pricingHistory}
|
||||
currentPrice={part.price}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto space-y-3">
|
||||
<Link
|
||||
href={`/gunbuilder?select=${categoryId}:${part.id}`}
|
||||
className="block w-full rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-3 text-sm font-medium text-amber-200 hover:bg-amber-400/15 text-center transition-colors"
|
||||
>
|
||||
Add to Build
|
||||
</Link>
|
||||
<a
|
||||
href={part.affiliateUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 text-center transition-colors"
|
||||
>
|
||||
View Retailer
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,27 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CATEGORIES, PARTS } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||
|
||||
type ViewMode = "card" | "list";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
brandName: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
mainImageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
/**
|
||||
* Map from category id -> Ballistic partRole(s)
|
||||
* Keep this in sync with whatever you’re using on the main /gunbuilder page.
|
||||
*/
|
||||
const CATEGORY_TO_PART_ROLES: Record<CategoryId, string[]> = {
|
||||
upper: ["upper-receiver"],
|
||||
barrel: ["barrel"],
|
||||
handguard: ["handguard"],
|
||||
chargingHandle: ["charging-handle"],
|
||||
buffer: ["buffer-kit"],
|
||||
lowerParts: ["lower-parts-kit"],
|
||||
sights: ["sight"],
|
||||
};
|
||||
|
||||
export default function CategoryPage() {
|
||||
const params = useParams();
|
||||
const categoryId = params.categoryId as CategoryId;
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [parts, setParts] = useState<Part[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const category = useMemo(
|
||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
||||
[categoryId],
|
||||
);
|
||||
|
||||
const parts = useMemo(
|
||||
() => PARTS.filter((p) => p.categoryId === categoryId),
|
||||
[categoryId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!categoryId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchCategoryParts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? [];
|
||||
|
||||
const search = new URLSearchParams();
|
||||
search.set("platform", "AR-15");
|
||||
roles.forEach((r) => search.append("partRoles", r));
|
||||
|
||||
const url = `${API_BASE_URL}/api/products/gunbuilder${
|
||||
roles.length ? `?${search.toString()}` : ""
|
||||
}`;
|
||||
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load products (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
|
||||
const normalized: Part[] = data.map((p) => ({
|
||||
id: p.uuid,
|
||||
categoryId,
|
||||
name: p.name,
|
||||
brand: p.brandName,
|
||||
price: p.price ?? 0,
|
||||
// these fields exist in the Part type even if you’re not using them yet
|
||||
imageUrl: p.mainImageUrl ?? undefined,
|
||||
url: p.buyUrl ?? undefined,
|
||||
notes: undefined,
|
||||
}));
|
||||
|
||||
setParts(normalized);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") return;
|
||||
setError(err.message ?? "Failed to load products");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCategoryParts();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [categoryId]);
|
||||
|
||||
if (!category) {
|
||||
return (
|
||||
@@ -63,11 +145,11 @@ export default function CategoryPage() {
|
||||
{category.name} <span className="text-amber-300">Parts</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||
Browse all available {category.name.toLowerCase()} options for your
|
||||
build.
|
||||
Browse all available {category.name.toLowerCase()} options for
|
||||
your build, pulled live from your Ballistic backend.
|
||||
</p>
|
||||
</div>
|
||||
{parts.length > 0 && (
|
||||
{!loading && !error && parts.length > 0 && (
|
||||
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
|
||||
<button
|
||||
type="button"
|
||||
@@ -100,7 +182,15 @@ export default function CategoryPage() {
|
||||
|
||||
{/* Parts Grid/List */}
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||
{parts.length === 0 ? (
|
||||
{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>
|
||||
) : parts.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 text-center py-8">
|
||||
No parts available for this category yet.
|
||||
</p>
|
||||
@@ -118,7 +208,8 @@ export default function CategoryPage() {
|
||||
<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>
|
||||
{part.brand}{" "}
|
||||
<span className="font-normal">— {part.name}</span>
|
||||
</div>
|
||||
{part.notes && (
|
||||
<p className="mt-1 text-xs text-zinc-400 line-clamp-2">
|
||||
@@ -160,7 +251,8 @@ export default function CategoryPage() {
|
||||
className="block"
|
||||
>
|
||||
<div className="text-sm font-semibold text-zinc-50">
|
||||
{part.brand} <span className="font-normal">— {part.name}</span>
|
||||
{part.brand}{" "}
|
||||
<span className="font-normal">— {part.name}</span>
|
||||
</div>
|
||||
{part.notes && (
|
||||
<p className="mt-1 text-xs text-zinc-400 line-clamp-1">
|
||||
@@ -195,5 +287,4 @@ export default function CategoryPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
+122
-26
@@ -3,10 +3,38 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { CATEGORIES, PARTS } from "@/data/gunbuilderParts";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||
import { CategoryColumn } from "@/components/CategoryColumn";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
brandName: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
mainImageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
// Map backend partRole to your existing CategoryId values.
|
||||
// Make sure these string values match the `id` fields in CATEGORIES.
|
||||
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
|
||||
"upper-receiver": "upper" as CategoryId,
|
||||
barrel: "barrel" as CategoryId,
|
||||
handguard: "handguard" as CategoryId,
|
||||
"charging-handle": "chargingHandle" as CategoryId,
|
||||
"buffer-kit": "buffer" as CategoryId,
|
||||
"lower-parts-kit": "lowerParts" as CategoryId,
|
||||
sight: "sights" as CategoryId,
|
||||
};
|
||||
|
||||
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
@@ -14,6 +42,9 @@ const STORAGE_KEY = "gunbuilder-build-state";
|
||||
export default function GunbuilderPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [parts, setParts] = useState<Part[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [build, setBuild] = useState<BuildState>(() => {
|
||||
// Initialize from localStorage if available
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -29,6 +60,60 @@ export default function GunbuilderPage() {
|
||||
return {};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchProducts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load products (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
|
||||
const normalized: Part[] = data
|
||||
.map((p) => {
|
||||
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
|
||||
if (!categoryId) {
|
||||
// Skip any parts we don't know how to map yet
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
// Use the backend UUID as the stable id for the UI
|
||||
id: p.uuid,
|
||||
categoryId,
|
||||
name: p.name,
|
||||
brand: p.brandName,
|
||||
price: p.price ?? 0,
|
||||
imageUrl: p.mainImageUrl ?? undefined,
|
||||
url: p.buyUrl ?? undefined,
|
||||
} as Part;
|
||||
})
|
||||
.filter(Boolean) as Part[];
|
||||
|
||||
setParts(normalized);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") return;
|
||||
setError(err.message ?? "Failed to load products");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProducts();
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Handle URL query parameter for part selection
|
||||
useEffect(() => {
|
||||
const selectParam = searchParams.get("select");
|
||||
@@ -60,20 +145,20 @@ export default function GunbuilderPage() {
|
||||
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
||||
const grouped = {} as Record<CategoryId, Part[]>;
|
||||
for (const category of CATEGORIES) {
|
||||
grouped[category.id] = PARTS.filter(
|
||||
grouped[category.id] = parts.filter(
|
||||
(p) => p.categoryId === category.id,
|
||||
);
|
||||
}
|
||||
return grouped;
|
||||
}, []);
|
||||
}, [parts]);
|
||||
|
||||
const selectedParts: Part[] = useMemo(() => {
|
||||
return Object.entries(build)
|
||||
.map(([categoryId, partId]) =>
|
||||
PARTS.find((p) => p.id === partId && p.categoryId === categoryId),
|
||||
parts.find((p) => p.id === partId && p.categoryId === categoryId),
|
||||
)
|
||||
.filter(Boolean) as Part[];
|
||||
}, [build]);
|
||||
}, [build, parts]);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
|
||||
@@ -100,9 +185,9 @@ export default function GunbuilderPage() {
|
||||
Gunbuilder <span className="text-amber-300">Prototype</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||
Choose one part per category to assemble an AR-15 build. Prices
|
||||
and links are mock data for now—but the flow is close to what
|
||||
the real tool will be.
|
||||
Choose one part per category to assemble an AR-15 build. Parts are
|
||||
loaded from the live merchant feed (Aero Precision for now), using
|
||||
your Ballistic backend API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -121,23 +206,34 @@ export default function GunbuilderPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-[3fr,1.25fr]">
|
||||
{/* Categories/parts */}
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{CATEGORIES.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="border border-zinc-800 rounded-md p-2 md:p-3 bg-zinc-950/60"
|
||||
>
|
||||
<CategoryColumn
|
||||
category={category}
|
||||
parts={partsByCategory[category.id]}
|
||||
selectedPartId={build[category.id]}
|
||||
onSelectPart={(partId) =>
|
||||
handleSelectPart(category.id, partId)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{loading && (
|
||||
<p className="text-sm text-zinc-500">Loading parts from backend…</p>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<p className="text-sm text-red-400">
|
||||
{error} — check that the Ballistic API is running and CORS is
|
||||
configured.
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{CATEGORIES.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="border border-zinc-800 rounded-md p-2 md:p-3 bg-zinc-950/60"
|
||||
>
|
||||
<CategoryColumn
|
||||
category={category}
|
||||
parts={partsByCategory[category.id]}
|
||||
selectedPartId={build[category.id]}
|
||||
onSelectPart={(partId) =>
|
||||
handleSelectPart(category.id, partId)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Build Summary */}
|
||||
@@ -156,7 +252,7 @@ export default function GunbuilderPage() {
|
||||
<ul className="space-y-2">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const selectedPartId = build[cat.id];
|
||||
const part = PARTS.find(
|
||||
const part = parts.find(
|
||||
(p) => p.id === selectedPartId && p.categoryId === cat.id,
|
||||
);
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user