pulling products from spring api. live data

This commit is contained in:
2025-11-30 09:24:00 -05:00
parent 2441542a90
commit d53a58e994
74 changed files with 1627 additions and 538 deletions
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
+9 -4
View File
@@ -1,9 +1,9 @@
{ {
"pages": { "pages": {
"/gunbuilder/page": [ "/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/gunbuilder/page.js" "static/chunks/app/page.js"
], ],
"/layout": [ "/layout": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
@@ -11,15 +11,20 @@
"static/css/app/layout.css", "static/css/app/layout.css",
"static/chunks/app/layout.js" "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": [ "/gunbuilder/[categoryId]/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/gunbuilder/[categoryId]/page.js" "static/chunks/app/gunbuilder/[categoryId]/page.js"
], ],
"/gunbuilder/build/page": [ "/gunbuilder/[categoryId]/[partId]/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.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 -1
View File
@@ -1,5 +1,5 @@
{ {
"/gunbuilder/[categoryId]/page": "app/gunbuilder/[categoryId]/page.js", "/gunbuilder/[categoryId]/page": "app/gunbuilder/[categoryId]/page.js",
"/gunbuilder/page": "app/gunbuilder/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
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{ {
"node": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "ZxASFi7MiqyHozChjYSoQefUKtZDWURXFCvYTejSOjs=" "encryptionKey": "PyCIl678GNAha8RPGKgUOQBg4GVuvLwB0fMPvLGLebg="
} }
+1 -1
View File
@@ -125,7 +125,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("cacb2d11823559d2") /******/ __webpack_require__.h = () => ("d594e5cfece503fe")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ /* webpack/runtime/hasOwnProperty shorthand */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -25,7 +25,7 @@ eval(__webpack_require__.ts("Promise.resolve(/*! import() eager */).then(__webpa
/***/ (function(module, __webpack_exports__, __webpack_require__) { /***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict"; "use strict";
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"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
+1 -1
View File
@@ -192,7 +192,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ !function() { /******/ !function() {
/******/ __webpack_require__.h = function() { return "95f881488f2e5382"; } /******/ __webpack_require__.h = function() { return "f608a9ecbacea728"; }
/******/ }(); /******/ }();
/******/ /******/
/******/ /* webpack/runtime/global */ /******/ /* webpack/runtime/global */
+123 -75
View File
@@ -520,6 +520,10 @@ video {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
.mb-1 { .mb-1 {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
@@ -586,14 +590,17 @@ video {
.h-16 { .h-16 {
height: 4rem; height: 4rem;
} }
.h-full { .h-32 {
height: 100%; height: 8rem;
} }
.h-4 { .h-4 {
height: 1rem; height: 1rem;
} }
.h-32 { .h-full {
height: 8rem; height: 100%;
}
.max-h-80 {
max-height: 20rem;
} }
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
@@ -601,15 +608,18 @@ video {
.w-16 { .w-16 {
width: 4rem; width: 4rem;
} }
.w-full {
width: 100%;
}
.w-4 { .w-4 {
width: 1rem; width: 1rem;
} }
.w-full {
width: 100%;
}
.min-w-0 { .min-w-0 {
min-width: 0px; min-width: 0px;
} }
.max-w-4xl {
max-width: 56rem;
}
.max-w-6xl { .max-w-6xl {
max-width: 72rem; max-width: 72rem;
} }
@@ -619,8 +629,8 @@ video {
.max-w-xl { .max-w-xl {
max-width: 36rem; max-width: 36rem;
} }
.max-w-4xl { .max-w-5xl {
max-width: 56rem; max-width: 64rem;
} }
.flex-1 { .flex-1 {
flex: 1 1 0%; flex: 1 1 0%;
@@ -670,6 +680,9 @@ video {
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1rem * var(--tw-space-y-reverse)); margin-bottom: calc(1rem * var(--tw-space-y-reverse));
} }
.overflow-hidden {
overflow: hidden;
}
.overflow-y-auto { .overflow-y-auto {
overflow-y: auto; overflow-y: auto;
} }
@@ -698,12 +711,22 @@ video {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(251 191 36 / var(--tw-border-opacity)); 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-amber-400\/60 {
border-color: rgb(251 191 36 / 0.6); border-color: rgb(251 191 36 / 0.6);
} }
.border-amber-400\/70 { .border-amber-400\/70 {
border-color: rgb(251 191 36 / 0.7); 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 { .border-zinc-700 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(63 63 70 / var(--tw-border-opacity)); border-color: rgb(63 63 70 / var(--tw-border-opacity));
@@ -712,23 +735,22 @@ video {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(39 39 42 / var(--tw-border-opacity)); 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 { .bg-amber-400\/10 {
background-color: rgb(251 191 36 / 0.1); background-color: rgb(251 191 36 / 0.1);
} }
.bg-amber-400\/20 {
background-color: rgb(251 191 36 / 0.2);
}
.bg-black { .bg-black {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 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 { .bg-zinc-800 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(39 39 42 / var(--tw-bg-opacity)); background-color: rgb(39 39 42 / var(--tw-bg-opacity));
@@ -740,6 +762,9 @@ video {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(24 24 27 / var(--tw-bg-opacity)); 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 { .bg-zinc-900\/30 {
background-color: rgb(24 24 27 / 0.3); background-color: rgb(24 24 27 / 0.3);
} }
@@ -752,17 +777,23 @@ video {
.bg-zinc-950\/80 { .bg-zinc-950\/80 {
background-color: rgb(9 9 11 / 0.8); background-color: rgb(9 9 11 / 0.8);
} }
.bg-green-500\/10 { .bg-amber-400 {
background-color: rgb(34 197 94 / 0.1); --tw-bg-opacity: 1;
background-color: rgb(251 191 36 / var(--tw-bg-opacity));
} }
.bg-amber-400\/20 { .bg-zinc-900\/60 {
background-color: rgb(251 191 36 / 0.2); background-color: rgb(24 24 27 / 0.6);
} }
.bg-red-500\/20 { .bg-zinc-950 {
background-color: rgb(239 68 68 / 0.2); --tw-bg-opacity: 1;
background-color: rgb(9 9 11 / var(--tw-bg-opacity));
} }
.bg-zinc-900\/20 { .bg-zinc-950\/70 {
background-color: rgb(24 24 27 / 0.2); background-color: rgb(9 9 11 / 0.7);
}
.object-contain {
-o-object-fit: contain;
object-fit: contain;
} }
.p-1 { .p-1 {
padding: 0.25rem; padding: 0.25rem;
@@ -779,6 +810,14 @@ video {
.p-8 { .p-8 {
padding: 2rem; 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 { .px-3 {
padding-left: 0.75rem; padding-left: 0.75rem;
padding-right: 0.75rem; padding-right: 0.75rem;
@@ -787,6 +826,18 @@ video {
padding-left: 1rem; padding-left: 1rem;
padding-right: 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 { .py-1 {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
@@ -803,6 +854,10 @@ video {
padding-top: 0.75rem; padding-top: 0.75rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
} }
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.py-6 { .py-6 {
padding-top: 1.5rem; padding-top: 1.5rem;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
@@ -811,29 +866,9 @@ video {
padding-top: 2rem; padding-top: 2rem;
padding-bottom: 2rem; padding-bottom: 2rem;
} }
.px-6 { .py-2\.5 {
padding-left: 1.5rem; padding-top: 0.625rem;
padding-right: 1.5rem; padding-bottom: 0.625rem;
}
.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;
} }
.pr-1 { .pr-1 {
padding-right: 0.25rem; padding-right: 0.25rem;
@@ -861,6 +896,9 @@ video {
font-size: 1.875rem; font-size: 1.875rem;
line-height: 2.25rem; line-height: 2.25rem;
} }
.text-\[0\.65rem\] {
font-size: 0.65rem;
}
.text-\[0\.7rem\] { .text-\[0\.7rem\] {
font-size: 0.7rem; font-size: 0.7rem;
} }
@@ -876,8 +914,9 @@ video {
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1rem; line-height: 1rem;
} }
.text-\[0\.65rem\] { .text-xl {
font-size: 0.65rem; font-size: 1.25rem;
line-height: 1.75rem;
} }
.font-medium { .font-medium {
font-weight: 500; font-weight: 500;
@@ -917,6 +956,14 @@ video {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(252 211 77 / var(--tw-text-opacity)); 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 { .text-zinc-300 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(212 212 216 / var(--tw-text-opacity)); color: rgb(212 212 216 / var(--tw-text-opacity));
@@ -937,13 +984,13 @@ video {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(82 82 91 / var(--tw-text-opacity)); color: rgb(82 82 91 / var(--tw-text-opacity));
} }
.text-green-400 { .text-black {
--tw-text-opacity: 1; --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; --tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity)); color: rgb(228 228 231 / var(--tw-text-opacity));
} }
.underline { .underline {
text-decoration-line: underline; text-decoration-line: underline;
@@ -951,12 +998,12 @@ video {
.line-through { .line-through {
text-decoration-line: line-through; text-decoration-line: line-through;
} }
.opacity-40 {
opacity: 0.4;
}
.opacity-0 { .opacity-0 {
opacity: 0; opacity: 0;
} }
.opacity-40 {
opacity: 0.4;
}
.opacity-60 { .opacity-60 {
opacity: 0.6; opacity: 0.6;
} }
@@ -968,13 +1015,13 @@ video {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms; transition-duration: 150ms;
} }
.transition-colors { .transition-all {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms; transition-duration: 150ms;
} }
.transition-all { .transition-colors {
transition-property: all; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms; transition-duration: 150ms;
} }
@@ -994,6 +1041,10 @@ body {
color: rgb(250 250 250 / var(--tw-text-opacity)); 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 { .hover\:border-zinc-500:hover {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(113 113 122 / var(--tw-border-opacity)); 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)); border-color: rgb(82 82 91 / var(--tw-border-opacity));
} }
.hover\:border-amber-400\/60:hover { .hover\:bg-amber-400\/10:hover {
border-color: rgb(251 191 36 / 0.6); background-color: rgb(251 191 36 / 0.1);
} }
.hover\:bg-amber-400\/15:hover { .hover\:bg-amber-400\/15:hover {
@@ -1022,8 +1073,9 @@ body {
background-color: rgb(39 39 42 / var(--tw-bg-opacity)); background-color: rgb(39 39 42 / var(--tw-bg-opacity));
} }
.hover\:bg-amber-400\/10:hover { .hover\:bg-amber-300:hover {
background-color: rgb(251 191 36 / 0.1); --tw-bg-opacity: 1;
background-color: rgb(252 211 77 / var(--tw-bg-opacity));
} }
.hover\:text-amber-200:hover { .hover\:text-amber-200:hover {
@@ -1036,14 +1088,6 @@ body {
color: rgb(212 212 216 / var(--tw-text-opacity)); 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 { .group:hover .group-hover\:opacity-100 {
opacity: 1; opacity: 1;
} }
@@ -1077,6 +1121,10 @@ body {
grid-template-columns: 1fr 1fr; 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 { .md\:flex-row {
flex-direction: 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":[]}
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":[]}
@@ -11,7 +11,7 @@ self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules /******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ !function() { /******/ !function() {
/******/ __webpack_require__.h = function() { return "9d51351cbbe0e509"; } /******/ __webpack_require__.h = function() { return "f608a9ecbacea728"; }
/******/ }(); /******/ }();
/******/ /******/
/******/ } /******/ }
@@ -11,7 +11,7 @@ self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules /******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ !function() { /******/ !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
View File
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 // File: /Users/sean/Dev/gunbuilder-prototype/app/page.tsx
import * as entry from '../../../../../app/gunbuilder/build/page.js' import * as entry from '../../../app/page.js'
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.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 // Check that the entry is a valid entry
checkFields<Diff<{ checkFields<Diff<{
+170 -166
View File
@@ -1,59 +1,92 @@
"use client"; "use client";
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { CATEGORIES, PARTS } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/gunbuilder";
import { getPricingHistory, getRetailerOffers } from "./data";
import { PricingHistoryGraph } from "@/components/PricingHistoryGraph"; type GunbuilderProductFromApi = {
import { RetailersList } from "@/components/RetailersList"; 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() { export default function PartDetailPage() {
const params = useParams(); const params = useParams();
const categoryId = params.categoryId as CategoryId; 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( const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId), () => CATEGORIES.find((c) => c.id === categoryId),
[categoryId], [categoryId],
); );
const part = useMemo( useEffect(() => {
() => PARTS.find((p) => p.id === partId && p.categoryId === categoryId), if (!partId) return;
[partId, categoryId],
);
// Get pricing history and retailer offers const controller = new AbortController();
// TODO: Replace with API calls when endpoints are ready
const pricingHistory = useMemo(
() => (part ? getPricingHistory(part.id, part.price) : []),
[part],
);
const retailerOffers = useMemo( async function fetchProduct() {
() => try {
part setLoading(true);
? getRetailerOffers(part.id, part.price, part.affiliateUrl) setError(null);
: [],
[part],
);
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 ( return (
<main className="min-h-screen bg-black text-zinc-50"> <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="mx-auto max-w-6xl px-4 py-6 lg:py-10">
<div className="text-center"> <h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
<h1 className="text-2xl font-semibold text-zinc-50 mb-4"> <Link
Product Not Found href="/gunbuilder"
</h1> className="text-amber-300 hover:text-amber-200 underline"
<Link >
href="/gunbuilder" Return to Gunbuilder
className="text-amber-300 hover:text-amber-200 underline" </Link>
>
Return to Gunbuilder
</Link>
</div>
</div> </div>
</main> </main>
); );
@@ -61,152 +94,123 @@ export default function PartDetailPage() {
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <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="mx-auto max-w-5xl px-4 py-6 lg:py-10">
{/* Header */} {/* Breadcrumbs */}
<header className="mb-6"> <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 <Link
href={`/gunbuilder/${categoryId}`} 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> </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> <div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500"> <h1 className="text-xl font-semibold mb-2">Product Unavailable</h1>
Shadow Standard <p className="text-sm text-zinc-500 mb-4">
</p> {error ?? "We couldnt find this product."}
<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
</p> </p>
<Link
href={`/gunbuilder/${categoryId}`}
className="text-amber-300 hover:text-amber-200 underline text-sm"
>
Back to {category.name} parts
</Link>
</div> </div>
</header> ) : (
<div className="grid gap-6 md:grid-cols-[minmax(0,2fr)_minmax(0,1.3fr)] items-start">
{/* Product Details */} {/* Left: image + meta */}
<div className="grid gap-4 lg:grid-cols-[2fr,1fr]"> <div className="space-y-4">
{/* Main Content */} {product.mainImageUrl && (
<section className="space-y-4"> <div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950">
{/* Product Information */} {/* eslint-disable-next-line @next/next/no-img-element */}
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6"> <img
<div className="grid gap-6 md:grid-cols-[1fr,1fr]"> src={product.mainImageUrl}
{/* Product Info */} alt={product.name}
<div> className="w-full object-contain max-h-80 bg-zinc-950"
<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> </div>
)}
<div className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
{product.brandName}
</div> </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> </div>
{/* Retailers List */} {/* Right: pricing + actions */}
{retailerOffers.length > 0 && ( <aside className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6"> <div>
<RetailersList retailers={retailerOffers} /> <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> </div>
)}
</section>
{/* Sidebar */} <div className="space-y-2">
<aside className="rounded-lg border border-zinc-800 bg-zinc-950/80 p-4 md:p-6 flex flex-col"> <Link
<div className="mb-4"> href={`/gunbuilder?select=${categoryId}:${product.uuid}`}
<div className="text-xs text-zinc-400 mb-2">Price</div> 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"
<div className="text-3xl font-semibold text-amber-300"> >
${part.price.toFixed(2)} 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>
</div> </aside>
</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>
</div> </div>
</main> </main>
); );
} }
+105 -14
View File
@@ -1,27 +1,109 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { CATEGORIES, PARTS } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId, Part } from "@/types/gunbuilder";
type ViewMode = "card" | "list"; 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 youre 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() { export default function CategoryPage() {
const params = useParams(); const params = useParams();
const categoryId = params.categoryId as CategoryId; const categoryId = params.categoryId as CategoryId;
const [viewMode, setViewMode] = useState<ViewMode>("list"); 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( const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId), () => CATEGORIES.find((c) => c.id === categoryId),
[categoryId], [categoryId],
); );
const parts = useMemo( useEffect(() => {
() => PARTS.filter((p) => p.categoryId === categoryId), if (!categoryId) return;
[categoryId],
); 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 youre 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) { if (!category) {
return ( return (
@@ -63,11 +145,11 @@ export default function CategoryPage() {
{category.name} <span className="text-amber-300">Parts</span> {category.name} <span className="text-amber-300">Parts</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl"> <p className="mt-2 text-sm text-zinc-400 max-w-xl">
Browse all available {category.name.toLowerCase()} options for your Browse all available {category.name.toLowerCase()} options for
build. your build, pulled live from your Ballistic backend.
</p> </p>
</div> </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"> <div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button <button
type="button" type="button"
@@ -100,7 +182,15 @@ export default function CategoryPage() {
{/* Parts Grid/List */} {/* Parts Grid/List */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4"> <section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
{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"> <p className="text-sm text-zinc-500 text-center py-8">
No parts available for this category yet. No parts available for this category yet.
</p> </p>
@@ -118,7 +208,8 @@ export default function CategoryPage() {
<div className="flex justify-between items-center gap-2 mb-3"> <div className="flex justify-between items-center gap-2 mb-3">
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-semibold text-zinc-50"> <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> </div>
{part.notes && ( {part.notes && (
<p className="mt-1 text-xs text-zinc-400 line-clamp-2"> <p className="mt-1 text-xs text-zinc-400 line-clamp-2">
@@ -160,7 +251,8 @@ export default function CategoryPage() {
className="block" className="block"
> >
<div className="text-sm font-semibold text-zinc-50"> <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> </div>
{part.notes && ( {part.notes && (
<p className="mt-1 text-xs text-zinc-400 line-clamp-1"> <p className="mt-1 text-xs text-zinc-400 line-clamp-1">
@@ -196,4 +288,3 @@ export default function CategoryPage() {
</main> </main>
); );
} }
+122 -26
View File
@@ -3,10 +3,38 @@
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation"; 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 type { CategoryId, Part } from "@/types/gunbuilder";
import { CategoryColumn } from "@/components/CategoryColumn"; 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 type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state"; const STORAGE_KEY = "gunbuilder-build-state";
@@ -14,6 +42,9 @@ const STORAGE_KEY = "gunbuilder-build-state";
export default function GunbuilderPage() { export default function GunbuilderPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); 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>(() => { const [build, setBuild] = useState<BuildState>(() => {
// Initialize from localStorage if available // Initialize from localStorage if available
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -29,6 +60,60 @@ export default function GunbuilderPage() {
return {}; 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 // Handle URL query parameter for part selection
useEffect(() => { useEffect(() => {
const selectParam = searchParams.get("select"); const selectParam = searchParams.get("select");
@@ -60,20 +145,20 @@ export default function GunbuilderPage() {
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => { const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>; const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) { for (const category of CATEGORIES) {
grouped[category.id] = PARTS.filter( grouped[category.id] = parts.filter(
(p) => p.categoryId === category.id, (p) => p.categoryId === category.id,
); );
} }
return grouped; return grouped;
}, []); }, [parts]);
const selectedParts: Part[] = useMemo(() => { const selectedParts: Part[] = useMemo(() => {
return Object.entries(build) return Object.entries(build)
.map(([categoryId, partId]) => .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[]; .filter(Boolean) as Part[];
}, [build]); }, [build, parts]);
const totalPrice = useMemo( const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0), () => selectedParts.reduce((sum, p) => sum + p.price, 0),
@@ -100,9 +185,9 @@ export default function GunbuilderPage() {
Gunbuilder <span className="text-amber-300">Prototype</span> Gunbuilder <span className="text-amber-300">Prototype</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl"> <p className="mt-2 text-sm text-zinc-400 max-w-xl">
Choose one part per category to assemble an AR-15 build. Prices Choose one part per category to assemble an AR-15 build. Parts are
and links are mock data for nowbut the flow is close to what loaded from the live merchant feed (Aero Precision for now), using
the real tool will be. your Ballistic backend API.
</p> </p>
</div> </div>
@@ -121,23 +206,34 @@ export default function GunbuilderPage() {
<div className="grid gap-4 lg:grid-cols-[3fr,1.25fr]"> <div className="grid gap-4 lg:grid-cols-[3fr,1.25fr]">
{/* Categories/parts */} {/* Categories/parts */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4"> <section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {loading && (
{CATEGORIES.map((category) => ( <p className="text-sm text-zinc-500">Loading parts from backend</p>
<div )}
key={category.id} {error && !loading && (
className="border border-zinc-800 rounded-md p-2 md:p-3 bg-zinc-950/60" <p className="text-sm text-red-400">
> {error} check that the Ballistic API is running and CORS is
<CategoryColumn configured.
category={category} </p>
parts={partsByCategory[category.id]} )}
selectedPartId={build[category.id]} {!loading && !error && (
onSelectPart={(partId) => <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
handleSelectPart(category.id, partId) {CATEGORIES.map((category) => (
} <div
/> key={category.id}
</div> className="border border-zinc-800 rounded-md p-2 md:p-3 bg-zinc-950/60"
))} >
</div> <CategoryColumn
category={category}
parts={partsByCategory[category.id]}
selectedPartId={build[category.id]}
onSelectPart={(partId) =>
handleSelectPart(category.id, partId)
}
/>
</div>
))}
</div>
)}
</section> </section>
{/* Build Summary */} {/* Build Summary */}
@@ -156,7 +252,7 @@ export default function GunbuilderPage() {
<ul className="space-y-2"> <ul className="space-y-2">
{CATEGORIES.map((cat) => { {CATEGORIES.map((cat) => {
const selectedPartId = build[cat.id]; const selectedPartId = build[cat.id];
const part = PARTS.find( const part = parts.find(
(p) => p.id === selectedPartId && p.categoryId === cat.id, (p) => p.id === selectedPartId && p.categoryId === cat.id,
); );
return ( return (