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": {
"/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 -1
View File
@@ -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
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": {},
"edge": {},
"encryptionKey": "ZxASFi7MiqyHozChjYSoQefUKtZDWURXFCvYTejSOjs="
"encryptionKey": "PyCIl678GNAha8RPGKgUOQBg4GVuvLwB0fMPvLGLebg="
}
+1 -1
View File
@@ -125,7 +125,7 @@
/******/
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("cacb2d11823559d2")
/******/ __webpack_require__.h = () => ("d594e5cfece503fe")
/******/ })();
/******/
/******/ /* 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__) {
"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 */
/******/ !function() {
/******/ __webpack_require__.h = function() { return "95f881488f2e5382"; }
/******/ __webpack_require__.h = function() { return "f608a9ecbacea728"; }
/******/ }();
/******/
/******/ /* webpack/runtime/global */
+123 -75
View File
@@ -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":[]}
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
/******/ /* webpack/runtime/getFullHash */
/******/ !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
/******/ /* 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
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
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<{
+171 -167
View File
@@ -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 couldnt 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>
);
}
}
+106 -15
View File
@@ -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 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() {
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 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) {
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
View File
@@ -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 nowbut 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 (