Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41e55404bf | |||
| ccc6e41724 | |||
| 64f288d8f7 | |||
| 14b25e7359 |
+1
-8
@@ -2,14 +2,7 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [],
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'placehold.co',
|
|
||||||
port: '',
|
|
||||||
pathname: '/**',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+270
-3
@@ -8,12 +8,15 @@
|
|||||||
"name": "pew-builder-nextjs",
|
"name": "pew-builder-nextjs",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.34.2",
|
||||||
"@headlessui/react": "^2.2.4",
|
"@headlessui/react": "^2.2.4",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"daisyui": "^4.7.3",
|
"daisyui": "^4.7.3",
|
||||||
"next": "15.3.4",
|
"next": "15.3.4",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -41,6 +44,46 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@auth/core": {
|
||||||
|
"version": "0.34.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz",
|
||||||
|
"integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@panva/hkdf": "^1.1.1",
|
||||||
|
"@types/cookie": "0.6.0",
|
||||||
|
"cookie": "0.6.0",
|
||||||
|
"jose": "^5.1.3",
|
||||||
|
"oauth4webapi": "^2.10.4",
|
||||||
|
"preact": "10.11.3",
|
||||||
|
"preact-render-to-string": "5.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"nodemailer": "^6.8.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.27.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||||
|
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
|
||||||
@@ -1048,6 +1091,15 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -1223,6 +1275,12 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cookie": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1258,7 +1316,7 @@
|
|||||||
"version": "19.1.8",
|
"version": "19.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
||||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -2504,6 +2562,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2545,7 +2612,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/culori": {
|
"node_modules/culori": {
|
||||||
@@ -4448,6 +4515,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "5.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||||
|
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -4803,6 +4879,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "4.24.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
|
||||||
|
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@panva/hkdf": "^1.0.2",
|
||||||
|
"cookie": "^0.7.0",
|
||||||
|
"jose": "^4.15.5",
|
||||||
|
"oauth": "^0.9.15",
|
||||||
|
"openid-client": "^5.4.0",
|
||||||
|
"preact": "^10.6.3",
|
||||||
|
"preact-render-to-string": "^5.1.19",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@auth/core": "0.34.2",
|
||||||
|
"next": "^12.2.5 || ^13 || ^14 || ^15",
|
||||||
|
"nodemailer": "^6.6.5",
|
||||||
|
"react": "^17.0.2 || ^18 || ^19",
|
||||||
|
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@auth/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-auth/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-auth/node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -4858,6 +4984,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.9.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "2.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
|
||||||
|
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -4991,6 +5132,60 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -5284,6 +5479,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
|
||||||
|
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pretty-format": "^3.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -5294,6 +5511,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -6591,6 +6814,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -6801,6 +7033,12 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||||
@@ -6826,6 +7064,35 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -9,12 +9,15 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.34.2",
|
||||||
"@headlessui/react": "^2.2.4",
|
"@headlessui/react": "^2.2.4",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"daisyui": "^4.7.3",
|
"daisyui": "^4.7.3",
|
||||||
"next": "15.3.4",
|
"next": "15.3.4",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
|
||||||
|
<p className="mb-6 text-gray-600 dark:text-gray-300 text-sm">
|
||||||
|
Enter your email address and we'll send you a link to reset your password.<br/>
|
||||||
|
<span className="text-primary font-semibold">(This feature is not yet implemented.)</span>
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="Email address"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
disabled={submitted}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full btn btn-primary"
|
||||||
|
disabled={submitted}
|
||||||
|
>
|
||||||
|
{submitted ? 'Check your email' : 'Send reset link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link href="/account/login" className="text-primary-600 hover:underline text-sm">Back to login</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function AccountLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{/* Simple navbar with back button */}
|
||||||
|
<nav className="bg-white dark:bg-neutral-900 shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-start h-16">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-100 focus:outline-none transition"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 mr-1"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const res = await signIn('credentials', {
|
||||||
|
redirect: false,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackUrl: searchParams.get('callbackUrl') || '/',
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res?.error) {
|
||||||
|
setError('Invalid email or password');
|
||||||
|
} else if (res?.ok) {
|
||||||
|
router.push(res.url || '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGoogle() {
|
||||||
|
setLoading(true);
|
||||||
|
await signIn('google', { callbackUrl: searchParams.get('callbackUrl') || '/' });
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Left side image or illustration */}
|
||||||
|
<div className="hidden lg:block relative w-0 flex-1 bg-gray-900">
|
||||||
|
{/* You can replace this with your own image or illustration */}
|
||||||
|
<img
|
||||||
|
className="absolute inset-0 h-full w-full object-cover opacity-80"
|
||||||
|
src="/window.svg"
|
||||||
|
alt="Login visual"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Right side form */}
|
||||||
|
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-20 xl:px-24 bg-white dark:bg-neutral-900 min-h-screen">
|
||||||
|
<div className="mx-auto w-full max-w-md space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Or{' '}
|
||||||
|
<Link href="/account/register" className="font-medium text-primary-600 hover:text-primary-500">
|
||||||
|
Sign Up For Free
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<input type="hidden" name="remember" value="true" />
|
||||||
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-address" className="sr-only">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email-address"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 placeholder-gray-500 dark:placeholder-neutral-400 text-gray-900 dark:text-white bg-white dark:bg-neutral-800 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Email address"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 placeholder-gray-500 dark:placeholder-neutral-400 text-gray-900 dark:text-white bg-white dark:bg-neutral-800 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 text-sm mt-2">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
<Link href="/account/forgot-password" className="font-medium text-primary-600 hover:text-primary-500">
|
||||||
|
Forgot your password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Social login buttons */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300 dark:border-neutral-700" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white dark:bg-neutral-900 text-gray-500 dark:text-gray-400">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoogle}
|
||||||
|
className="btn btn-outline flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-neutral-700 rounded-md shadow-sm bg-white dark:bg-neutral-800 text-sm font-medium text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Sign in with Google</span>
|
||||||
|
{/* Google Icon Placeholder */}
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M21.35 11.1H12v2.8h5.35c-.23 1.2-1.4 3.5-5.35 3.5-3.22 0-5.85-2.67-5.85-5.9s2.63-5.9 5.85-5.9c1.83 0 3.06.78 3.76 1.44l2.57-2.5C17.09 3.59 14.77 2.5 12 2.5 6.75 2.5 2.5 6.75 2.5 12s4.25 9.5 9.5 9.5c5.47 0 9.09-3.84 9.09-9.25 0-.62-.07-1.08-.16-1.55z" /></svg>
|
||||||
|
<span className="ml-2">Sign in with Google</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
router.replace('/account/login');
|
||||||
|
}
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return <div className="flex justify-center items-center h-64">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl mx-auto mt-12 p-6 bg-white dark:bg-neutral-900 rounded shadow">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Profile</h1>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div><span className="font-semibold">Name:</span> {session.user.name || 'N/A'}</div>
|
||||||
|
<div><span className="font-semibold">Email:</span> {session.user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const res = await fetch('/api/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(data.error || 'Registration failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Auto-login after registration
|
||||||
|
const signInRes = await signIn('credentials', {
|
||||||
|
redirect: false,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackUrl: '/account/profile',
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (signInRes?.ok) {
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
|
router.push('/account/login?registered=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-neutral-900">
|
||||||
|
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Create your account</h1>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="Email address"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
placeholder="Password"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500 pr-10"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-2 flex items-center text-xs text-gray-500 dark:text-gray-300"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => setShowPassword(v => !v)}
|
||||||
|
>
|
||||||
|
{showPassword ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link href="/account/login" className="text-primary-600 hover:underline text-sm">Already have an account? Sign in</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import NextAuth from 'next-auth';
|
||||||
|
import GoogleProvider from 'next-auth/providers/google';
|
||||||
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
|
|
||||||
|
// In-memory user store (for demo only)
|
||||||
|
type User = { email: string; password: string };
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var _users: User[] | undefined;
|
||||||
|
}
|
||||||
|
const users: User[] = global._users || (global._users = []);
|
||||||
|
|
||||||
|
const handler = NextAuth({
|
||||||
|
providers: [
|
||||||
|
GoogleProvider({
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||||
|
}),
|
||||||
|
CredentialsProvider({
|
||||||
|
name: 'Credentials',
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
// Check in-memory user store
|
||||||
|
const user = users.find(
|
||||||
|
(u) => u.email === credentials.email && u.password === credentials.password
|
||||||
|
);
|
||||||
|
if (user) {
|
||||||
|
return {
|
||||||
|
id: user.email,
|
||||||
|
email: user.email,
|
||||||
|
name: user.email.split('@')[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For demo, still allow the test user
|
||||||
|
if (credentials.email === "test@example.com" && credentials.password === "password") {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
email: credentials.email,
|
||||||
|
name: "Test User",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pages: {
|
||||||
|
signIn: '/account/login',
|
||||||
|
// signUp: '/account/register', // Uncomment when register page is ready
|
||||||
|
// error: '/account/error', // Uncomment when error page is ready
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async session({ session, token }) {
|
||||||
|
// Add any additional user data to the session here
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
// Add any additional user data to the JWT here
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// In-memory user store (for demo only)
|
||||||
|
type User = { email: string; password: string };
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var _users: User[] | undefined;
|
||||||
|
}
|
||||||
|
const users: User[] = global._users || (global._users = []);
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { email, password } = await req.json();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existing = users.find((u) => u.email === email);
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ error: 'User already exists.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new user
|
||||||
|
users.push({ email, password });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
+312
-111
@@ -4,6 +4,10 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import SearchInput from '@/components/SearchInput';
|
import SearchInput from '@/components/SearchInput';
|
||||||
|
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||||
|
import { useBuildStore } from '@/store/useBuildStore';
|
||||||
|
import { mockProducts } from '@/mock/product';
|
||||||
|
import { Dialog } from '@headlessui/react';
|
||||||
|
|
||||||
// AR-15 Build Requirements grouped by main categories
|
// AR-15 Build Requirements grouped by main categories
|
||||||
const buildGroups = [
|
const buildGroups = [
|
||||||
@@ -215,12 +219,60 @@ const categories = ["All", "Upper", "Lower", "Accessory"];
|
|||||||
type SortField = 'name' | 'category' | 'estimatedPrice' | 'status';
|
type SortField = 'name' | 'category' | 'estimatedPrice' | 'status';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
// Map checklist component categories to product categories for filtering
|
||||||
|
const getProductCategory = (componentCategory: string): string => {
|
||||||
|
const categoryMap: Record<string, string> = {
|
||||||
|
'Upper': 'Upper Receiver', // Default to Upper Receiver for Upper category
|
||||||
|
'Lower': 'Lower Receiver', // Default to Lower Receiver for Lower category
|
||||||
|
'Accessory': 'Magazine', // Default to Magazine for Accessory category
|
||||||
|
};
|
||||||
|
|
||||||
|
return categoryMap[componentCategory] || 'Magazine';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map specific checklist components to product categories
|
||||||
|
const getProductCategoryForComponent = (componentName: string): string => {
|
||||||
|
const componentMap: Record<string, string> = {
|
||||||
|
// Upper components
|
||||||
|
'Upper Receiver': 'Upper Receiver',
|
||||||
|
'Barrel': 'Barrel',
|
||||||
|
'Bolt Carrier Group (BCG)': 'BCG',
|
||||||
|
'Charging Handle': 'Charging Handle',
|
||||||
|
'Gas Block': 'Gas Block',
|
||||||
|
'Gas Tube': 'Gas Tube',
|
||||||
|
'Handguard': 'Handguard',
|
||||||
|
'Muzzle Device': 'Muzzle Device',
|
||||||
|
|
||||||
|
// Lower components
|
||||||
|
'Lower Receiver': 'Lower Receiver',
|
||||||
|
'Trigger': 'Trigger',
|
||||||
|
'Trigger Guard': 'Lower Receiver',
|
||||||
|
'Pistol Grip': 'Lower Receiver',
|
||||||
|
'Buffer Tube': 'Lower Receiver',
|
||||||
|
'Buffer': 'Lower Receiver',
|
||||||
|
'Buffer Spring': 'Lower Receiver',
|
||||||
|
'Stock': 'Stock',
|
||||||
|
|
||||||
|
// Accessories
|
||||||
|
'Magazine': 'Magazine',
|
||||||
|
'Sights': 'Magazine',
|
||||||
|
};
|
||||||
|
|
||||||
|
return componentMap[componentName] || 'Lower Receiver';
|
||||||
|
};
|
||||||
|
|
||||||
|
export { buildGroups };
|
||||||
export default function BuildPage() {
|
export default function BuildPage() {
|
||||||
const [sortField, setSortField] = useState<SortField>('name');
|
const [sortField, setSortField] = useState<SortField>('name');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const selectedParts = useBuildStore((state) => state.selectedParts);
|
||||||
|
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
|
||||||
|
const clearBuild = useBuildStore((state) => state.clearBuild);
|
||||||
|
const [showClearModal, setShowClearModal] = useState(false);
|
||||||
|
|
||||||
// Filter components
|
// Filter components
|
||||||
const filteredComponents = allComponents.filter(component => {
|
const filteredComponents = allComponents.filter(component => {
|
||||||
if (selectedCategory !== 'All' && component.category !== selectedCategory) {
|
if (selectedCategory !== 'All' && component.category !== selectedCategory) {
|
||||||
@@ -278,36 +330,66 @@ export default function BuildPage() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed': return 'bg-green-100 text-green-800';
|
||||||
return 'bg-green-100 text-green-800';
|
case 'in-progress': return 'bg-yellow-100 text-yellow-800';
|
||||||
case 'pending':
|
case 'pending': return 'bg-gray-100 text-gray-800';
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
case 'in-progress':
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalEstimatedCost = sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0);
|
const totalEstimatedCost = sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0);
|
||||||
const completedCount = sortedComponents.filter(component => component.status === 'completed').length;
|
const completedCount = sortedComponents.filter(component => selectedParts[component.id]).length;
|
||||||
|
const actualTotalCost = sortedComponents.reduce((sum, component) => {
|
||||||
|
const selected = selectedParts[component.id];
|
||||||
|
if (selected && selected.offers) {
|
||||||
|
return sum + Math.min(...selected.offers.map(offer => offer.price));
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const hasActiveFilters = selectedCategory !== 'All' || searchTerm;
|
const hasActiveFilters = selectedCategory !== 'All' || searchTerm;
|
||||||
|
|
||||||
|
// Check for restricted parts in the build
|
||||||
|
const getRestrictedParts = () => {
|
||||||
|
const restrictedParts: Array<{ part: any; restriction: string }> = [];
|
||||||
|
|
||||||
|
Object.values(selectedParts).forEach(selectedPart => {
|
||||||
|
if (selectedPart) {
|
||||||
|
const product = mockProducts.find(p => p.id === selectedPart.id);
|
||||||
|
if (product?.restrictions) {
|
||||||
|
const restrictions = product.restrictions;
|
||||||
|
if (restrictions.nfa) restrictedParts.push({ part: product, restriction: 'NFA' });
|
||||||
|
if (restrictions.sbr) restrictedParts.push({ part: product, restriction: 'SBR' });
|
||||||
|
if (restrictions.suppressor) restrictedParts.push({ part: product, restriction: 'Suppressor' });
|
||||||
|
if (restrictions.stateRestrictions && restrictions.stateRestrictions.length > 0) {
|
||||||
|
restrictedParts.push({ part: product, restriction: 'State Restrictions' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return restrictedParts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restrictedParts = getRestrictedParts();
|
||||||
|
const hasNFAItems = restrictedParts.some(rp => rp.restriction === 'NFA');
|
||||||
|
const hasSuppressors = restrictedParts.some(rp => rp.restriction === 'Suppressor');
|
||||||
|
const hasStateRestrictions = restrictedParts.some(rp => rp.restriction === 'State Restrictions');
|
||||||
|
const [showRestrictionAlerts, setShowRestrictionAlerts] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50">
|
<main className="min-h-screen bg-gray-50">
|
||||||
{/* Page Title */}
|
{/* Page Title */}
|
||||||
<div className="bg-white border-b">
|
<div className="bg-white border-b">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">AR-15 Build Checklist</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Plan Your Build</h1>
|
||||||
<p className="text-gray-600 mt-2">Track your build progress and find required components</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Build Summary */}
|
{/* Build Summary */}
|
||||||
<div className="bg-white border-b">
|
<div className="bg-white border-b">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-gray-900">{allComponents.length}</div>
|
<div className="text-2xl font-bold text-gray-900">{allComponents.length}</div>
|
||||||
<div className="text-sm text-gray-500">Total Components</div>
|
<div className="text-sm text-gray-500">Total Components</div>
|
||||||
@@ -320,38 +402,138 @@ export default function BuildPage() {
|
|||||||
<div className="text-2xl font-bold text-yellow-600">{allComponents.length - completedCount}</div>
|
<div className="text-2xl font-bold text-yellow-600">{allComponents.length - completedCount}</div>
|
||||||
<div className="text-sm text-gray-500">Remaining</div>
|
<div className="text-sm text-gray-500">Remaining</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2">
|
||||||
<div className="text-2xl font-bold text-blue-600">${totalEstimatedCost}</div>
|
<div>
|
||||||
<div className="text-sm text-gray-500">Estimated Cost</div>
|
<div className="text-2xl font-bold text-blue-600">${actualTotalCost.toFixed(2)}</div>
|
||||||
|
<div className="text-sm text-gray-500">Total Cost</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-error ml-0 md:ml-4"
|
||||||
|
onClick={() => setShowClearModal(true)}
|
||||||
|
>
|
||||||
|
Clear Build
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{/* Clear Build Modal */}
|
||||||
<div className="bg-white border-b">
|
<Dialog open={showClearModal} onClose={() => setShowClearModal(false)} className="fixed z-50 inset-0 overflow-y-auto">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="flex items-center justify-center min-h-screen px-4">
|
||||||
{/* Search Row */}
|
<div className="fixed inset-0 bg-black opacity-30" aria-hidden="true" />
|
||||||
<div className="mb-4 flex justify-end">
|
<div className="relative bg-white rounded-lg max-w-sm w-full mx-auto p-6 z-10 shadow-xl">
|
||||||
<div className="w-1/2">
|
<Dialog.Title className="text-lg font-bold mb-2">Clear Entire Build?</Dialog.Title>
|
||||||
<SearchInput
|
<Dialog.Description className="mb-4 text-gray-600">
|
||||||
label="Search Components"
|
Are you sure you want to clear your entire build? This action cannot be undone.
|
||||||
value={searchTerm}
|
</Dialog.Description>
|
||||||
onChange={setSearchTerm}
|
<div className="flex justify-end gap-2">
|
||||||
placeholder="Search components..."
|
<button
|
||||||
/>
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => setShowClearModal(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-error"
|
||||||
|
onClick={() => {
|
||||||
|
clearBuild();
|
||||||
|
setShowClearModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes, Clear Build
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Restriction Alerts */}
|
||||||
|
{restrictedParts.length > 0 && (
|
||||||
|
<div className="bg-white border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{restrictedParts.length} restriction{restrictedParts.length > 1 ? 's' : ''} detected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRestrictionAlerts(!showRestrictionAlerts)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{showRestrictionAlerts ? 'Hide' : 'Show'} details
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${showRestrictionAlerts ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRestrictionAlerts && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hasNFAItems && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-yellow-600 text-sm">🔒</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-yellow-800">NFA Items in Your Build</div>
|
||||||
|
<div className="text-xs text-yellow-700 mt-1">
|
||||||
|
Your build contains items that require National Firearms Act registration.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSuppressors && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-yellow-600 text-sm">🔇</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-yellow-800">Suppressor in Your Build</div>
|
||||||
|
<div className="text-xs text-yellow-700 mt-1">
|
||||||
|
Sound suppressor requires NFA registration. Processing times: 6-12 months.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasStateRestrictions && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-yellow-600 text-sm">🗺️</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-yellow-800">State Restrictions Apply</div>
|
||||||
|
<div className="text-xs text-yellow-700 mt-1">
|
||||||
|
Some items may be restricted in certain states. Verify local laws.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="bg-white border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||||
{/* Filters Row */}
|
{/* Filters Row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
{/* Category Dropdown */}
|
{/* Category Dropdown */}
|
||||||
<div>
|
<div className="col-span-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||||
<select
|
<select
|
||||||
value={selectedCategory}
|
value={selectedCategory}
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm"
|
||||||
>
|
>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<option key={category} value={category}>
|
<option key={category} value={category}>
|
||||||
@@ -362,9 +544,9 @@ export default function BuildPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<div>
|
<div className="col-span-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
<select className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm">
|
||||||
<option value="all">All Status</option>
|
<option value="all">All Status</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="in-progress">In Progress</option>
|
<option value="in-progress">In Progress</option>
|
||||||
@@ -373,12 +555,12 @@ export default function BuildPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort by */}
|
{/* Sort by */}
|
||||||
<div>
|
<div className="col-span-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
|
||||||
<select
|
<select
|
||||||
value={sortField}
|
value={sortField}
|
||||||
onChange={(e) => handleSort(e.target.value as SortField)}
|
onChange={(e) => handleSort(e.target.value as SortField)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm"
|
||||||
>
|
>
|
||||||
<option value="name">Name</option>
|
<option value="name">Name</option>
|
||||||
<option value="category">Category</option>
|
<option value="category">Category</option>
|
||||||
@@ -388,8 +570,8 @@ export default function BuildPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clear Filters */}
|
{/* Clear Filters */}
|
||||||
<div className="flex items-end">
|
<div className="col-span-1 flex items-end">
|
||||||
<button className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
<button className="w-full px-3 py-1.5 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm">
|
||||||
Clear Filters
|
Clear Filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,10 +581,10 @@ export default function BuildPage() {
|
|||||||
|
|
||||||
{/* Build Components Table */}
|
{/* Build Components Table */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
<div className="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto max-h-screen overflow-y-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
@@ -425,15 +607,12 @@ export default function BuildPage() {
|
|||||||
<span className="text-sm">{getSortIcon('category')}</span>
|
<span className="text-sm">{getSortIcon('category')}</span>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Description
|
|
||||||
</th>
|
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
onClick={() => handleSort('estimatedPrice')}
|
onClick={() => handleSort('estimatedPrice')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<span>Est. Price</span>
|
<span>Price</span>
|
||||||
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
|
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@@ -441,7 +620,7 @@ export default function BuildPage() {
|
|||||||
Notes
|
Notes
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Selected Product
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -458,81 +637,103 @@ export default function BuildPage() {
|
|||||||
return (
|
return (
|
||||||
<React.Fragment key={group.name}>
|
<React.Fragment key={group.name}>
|
||||||
{/* Group Header */}
|
{/* Group Header */}
|
||||||
<tr className="bg-gray-800">
|
<tr className="bg-gray-100">
|
||||||
<td colSpan={7} className="px-6 py-4">
|
<td colSpan={7} className="px-6 py-2">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div>
|
||||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
|
||||||
<span className="text-white font-semibold text-lg">
|
|
||||||
{group.name === 'Upper Parts' ? '🔫' :
|
|
||||||
group.name === 'Lower Parts' ? '🔧' : '📦'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<h3 className="text-xl font-bold text-white">{group.name}</h3>
|
|
||||||
<p className="text-gray-300">{group.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto text-right">
|
<div className="ml-auto text-right">
|
||||||
<div className="text-gray-300 font-medium">
|
<div className="text-xs text-gray-500 font-medium">
|
||||||
{groupComponents.length} components
|
{groupComponents.length} components
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Group Components */}
|
{/* Group Components */}
|
||||||
{groupComponents.map((component) => (
|
{groupComponents.map((component) => {
|
||||||
<tr key={component.id} className="hover:bg-gray-50">
|
const selected = selectedParts[component.id];
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
return (
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(component.status)}`}>
|
<tr key={component.id} className="hover:bg-gray-50">
|
||||||
{component.status}
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
</span>
|
{selected ? (
|
||||||
</td>
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
Selected
|
||||||
<div className="text-sm font-medium text-gray-900">
|
</span>
|
||||||
{component.name}
|
) : (
|
||||||
</div>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(component.status)}`}>
|
||||||
<div className="text-xs text-gray-500">
|
{component.status}
|
||||||
{component.required ? 'Required' : 'Optional'}
|
</span>
|
||||||
</div>
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
{selected ? (
|
||||||
{component.category}
|
<div>
|
||||||
</span>
|
<div className="text-sm font-medium text-gray-900">
|
||||||
</td>
|
<Link
|
||||||
<td className="px-6 py-4">
|
href={`/products/${selected.id}`}
|
||||||
<div className="text-sm text-gray-500 max-w-xs">
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
{component.description}
|
>
|
||||||
</div>
|
{selected.name}
|
||||||
</td>
|
</Link>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
</div>
|
||||||
<div className="text-sm font-semibold text-gray-900">
|
<div className="text-xs text-gray-500">
|
||||||
${component.estimatedPrice}
|
{selected.brand.name} · {component.required ? 'Required' : 'Optional'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-6 py-4">
|
) : (
|
||||||
<div className="text-sm text-gray-500 max-w-xs">
|
<div>
|
||||||
{component.notes}
|
<div className="text-sm font-medium text-gray-900">
|
||||||
</div>
|
{component.name}
|
||||||
</td>
|
</div>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<div className="text-xs text-gray-500">
|
||||||
<div className="flex space-x-2">
|
{component.required ? 'Required' : 'Optional'}
|
||||||
<Link
|
</div>
|
||||||
href={`/?category=${encodeURIComponent(component.category)}`}
|
</div>
|
||||||
className="bg-blue-600 text-white py-1 px-3 rounded text-xs hover:bg-blue-700 transition-colors"
|
)}
|
||||||
>
|
</td>
|
||||||
Find Parts
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
</Link>
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
<button className="bg-gray-100 text-gray-700 py-1 px-2 rounded text-xs hover:bg-gray-200 transition-colors">
|
{getProductCategoryForComponent(component.name)}
|
||||||
✓
|
</span>
|
||||||
</button>
|
</td>
|
||||||
</div>
|
<td className="px-6 py-4">
|
||||||
</td>
|
{selected ? (
|
||||||
</tr>
|
<div className="text-sm font-semibold text-gray-900">
|
||||||
))}
|
${Math.min(...selected.offers?.map(offer => offer.price) || [0]).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-500 max-w-xs">
|
||||||
|
{component.notes}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
{selected ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => removePartForComponent(component.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/parts?category=${encodeURIComponent(getProductCategoryForComponent(component.name))}`}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
Find Parts
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -562,7 +763,7 @@ export default function BuildPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
Total Value: ${sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0).toFixed(2)}
|
Total Value: ${actualTotalCost.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+21
-21
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import SearchInput from '@/components/SearchInput';
|
import SearchInput from '@/components/SearchInput';
|
||||||
|
|
||||||
// Sample build data
|
// Sample build data
|
||||||
const sampleBuilds = [
|
const sampleMyBuilds = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Budget AR-15 Build',
|
name: 'Budget AR-15 Build',
|
||||||
@@ -121,18 +121,18 @@ const sampleBuilds = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
type BuildStatus = 'completed' | 'in-progress' | 'planning';
|
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
|
||||||
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
|
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
export default function BuildsPage() {
|
export default function MyBuildsPage() {
|
||||||
const [sortField, setSortField] = useState<SortField>('completedDate');
|
const [sortField, setSortField] = useState<SortField>('completedDate');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||||
const [selectedStatus, setSelectedStatus] = useState<BuildStatus | 'all'>('all');
|
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
// Filter builds
|
// Filter builds
|
||||||
const filteredBuilds = sampleBuilds.filter(build => {
|
const filteredMyBuilds = sampleMyBuilds.filter(build => {
|
||||||
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
|
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ export default function BuildsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Sort builds
|
// Sort builds
|
||||||
const sortedBuilds = [...filteredBuilds].sort((a, b) => {
|
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
|
||||||
let aValue: any, bValue: any;
|
let aValue: any, bValue: any;
|
||||||
|
|
||||||
if (sortField === 'totalCost') {
|
if (sortField === 'totalCost') {
|
||||||
@@ -170,7 +170,7 @@ export default function BuildsPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusColor = (status: BuildStatus) => {
|
const getStatusColor = (status: MyBuildStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'bg-green-100 text-green-800';
|
return 'bg-green-100 text-green-800';
|
||||||
@@ -183,7 +183,7 @@ export default function BuildsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: BuildStatus) => {
|
const getStatusIcon = (status: MyBuildStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return '✓';
|
return '✓';
|
||||||
@@ -204,14 +204,14 @@ export default function BuildsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProgressPercentage = (build: typeof sampleBuilds[0]) => {
|
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
|
||||||
return Math.round((build.components.completed / build.components.total) * 100);
|
return Math.round((build.components.completed / build.components.total) * 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalBuilds = sampleBuilds.length;
|
const totalMyBuilds = sampleMyBuilds.length;
|
||||||
const completedBuilds = sampleBuilds.filter(build => build.status === 'completed').length;
|
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
|
||||||
const inProgressBuilds = sampleBuilds.filter(build => build.status === 'in-progress').length;
|
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
|
||||||
const totalValue = sampleBuilds.reduce((sum, build) => sum + build.totalCost, 0);
|
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50">
|
<main className="min-h-screen bg-gray-50">
|
||||||
@@ -228,15 +228,15 @@ export default function BuildsPage() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-gray-900">{totalBuilds}</div>
|
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
|
||||||
<div className="text-sm text-gray-500">Total Builds</div>
|
<div className="text-sm text-gray-500">Total Builds</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-green-600">{completedBuilds}</div>
|
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
|
||||||
<div className="text-sm text-gray-500">Completed</div>
|
<div className="text-sm text-gray-500">Completed</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-blue-600">{inProgressBuilds}</div>
|
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
|
||||||
<div className="text-sm text-gray-500">In Progress</div>
|
<div className="text-sm text-gray-500">In Progress</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -254,10 +254,10 @@ export default function BuildsPage() {
|
|||||||
<div className="mb-4 flex justify-end">
|
<div className="mb-4 flex justify-end">
|
||||||
<div className="w-1/2">
|
<div className="w-1/2">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
label="Search Builds"
|
label="Search My Builds"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={setSearchTerm}
|
onChange={setSearchTerm}
|
||||||
placeholder="Search builds..."
|
placeholder="Search my builds..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +269,7 @@ export default function BuildsPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
<select
|
<select
|
||||||
value={selectedStatus}
|
value={selectedStatus}
|
||||||
onChange={(e) => setSelectedStatus(e.target.value as BuildStatus | 'all')}
|
onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
>
|
>
|
||||||
<option value="all">All Status</option>
|
<option value="all">All Status</option>
|
||||||
@@ -319,9 +319,9 @@ export default function BuildsPage() {
|
|||||||
|
|
||||||
{/* Builds Grid */}
|
{/* Builds Grid */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{sortedBuilds.length > 0 ? (
|
{sortedMyBuilds.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{sortedBuilds.map((build) => (
|
{sortedMyBuilds.map((build) => (
|
||||||
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
|
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
|
||||||
{/* Build Image */}
|
{/* Build Image */}
|
||||||
<div className="h-48 bg-gray-200 relative">
|
<div className="h-48 bg-gray-200 relative">
|
||||||
|
|||||||
+3
-9
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
/* Focus styles for better accessibility */
|
/* Focus styles for better accessibility */
|
||||||
.focus-ring {
|
.focus-ring {
|
||||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900;
|
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-neutral-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card styles */
|
/* Card styles */
|
||||||
@@ -41,16 +41,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Button styles */
|
/* Button styles */
|
||||||
.btn-primary {
|
/* Removed custom .btn-primary to avoid DaisyUI conflict */
|
||||||
@apply bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
@apply bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input styles */
|
/* Input styles */
|
||||||
.input-field {
|
.input-field {
|
||||||
@apply w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors duration-200;
|
@apply w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-9
@@ -1,8 +1,7 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Navbar from "@/components/Navbar";
|
import Providers from "@/components/Providers";
|
||||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -17,14 +16,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning data-theme="pew">
|
||||||
<body className={`${inter.className} antialiased`}>
|
<body className={`${inter.className} antialiased`}>
|
||||||
<ThemeProvider>
|
<Providers>
|
||||||
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
|
{children}
|
||||||
<Navbar />
|
</Providers>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import SearchInput from '@/components/SearchInput';
|
||||||
|
|
||||||
|
// Sample build data
|
||||||
|
const sampleMyBuilds = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Budget AR-15 Build',
|
||||||
|
description: 'A cost-effective AR-15 build using quality budget components',
|
||||||
|
status: 'completed' as const,
|
||||||
|
totalCost: 847.50,
|
||||||
|
completedDate: '2024-01-15',
|
||||||
|
components: {
|
||||||
|
total: 18,
|
||||||
|
completed: 18,
|
||||||
|
categories: {
|
||||||
|
'Upper': 8,
|
||||||
|
'Lower': 7,
|
||||||
|
'Accessory': 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['Budget', '5.56 NATO', '16" Barrel'],
|
||||||
|
image: 'https://picsum.photos/400/250?random=1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Precision Long Range',
|
||||||
|
description: 'High-end precision build optimized for long-range accuracy',
|
||||||
|
status: 'in-progress' as const,
|
||||||
|
totalCost: 2847.99,
|
||||||
|
startedDate: '2024-02-01',
|
||||||
|
components: {
|
||||||
|
total: 18,
|
||||||
|
completed: 12,
|
||||||
|
categories: {
|
||||||
|
'Upper': 6,
|
||||||
|
'Lower': 4,
|
||||||
|
'Accessory': 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['Precision', '6.5 Creedmoor', '20" Barrel'],
|
||||||
|
image: 'https://picsum.photos/400/250?random=2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Home Defense Setup',
|
||||||
|
description: 'Compact AR-15 configured for home defense scenarios',
|
||||||
|
status: 'planning' as const,
|
||||||
|
totalCost: 0,
|
||||||
|
plannedDate: '2024-03-01',
|
||||||
|
components: {
|
||||||
|
total: 18,
|
||||||
|
completed: 0,
|
||||||
|
categories: {
|
||||||
|
'Upper': 0,
|
||||||
|
'Lower': 0,
|
||||||
|
'Accessory': 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['Home Defense', '5.56 NATO', '10.5" Barrel'],
|
||||||
|
image: 'https://picsum.photos/400/250?random=3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Competition Rifle',
|
||||||
|
description: 'Lightweight competition build for 3-gun matches',
|
||||||
|
status: 'completed' as const,
|
||||||
|
totalCost: 1650.75,
|
||||||
|
completedDate: '2023-12-10',
|
||||||
|
components: {
|
||||||
|
total: 18,
|
||||||
|
completed: 18,
|
||||||
|
categories: {
|
||||||
|
'Upper': 8,
|
||||||
|
'Lower': 7,
|
||||||
|
'Accessory': 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['Competition', '5.56 NATO', '18" Barrel'],
|
||||||
|
image: 'https://picsum.photos/400/250?random=4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: 'Suppressed SBR',
|
||||||
|
description: 'Short-barreled rifle build with suppressor integration',
|
||||||
|
status: 'in-progress' as const,
|
||||||
|
totalCost: 1895.25,
|
||||||
|
startedDate: '2024-01-20',
|
||||||
|
components: {
|
||||||
|
total: 18,
|
||||||
|
completed: 8,
|
||||||
|
categories: {
|
||||||
|
'Upper': 4,
|
||||||
|
'Lower': 3,
|
||||||
|
'Accessory': 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['SBR', 'Suppressed', '300 BLK'],
|
||||||
|
image: 'https://picsum.photos/400/250?random=5'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
name: 'Retro M16A1 Clone',
|
||||||
|
description: 'Faithful reproduction of the classic M16A1 rifle',
|
||||||
|
status: 'planning' as const,
|
||||||
|
totalCost: 0,
|
||||||
|
plannedDate: '2024-04-01',
|
||||||
|
components: {
|
||||||
|
total: 18,
|
||||||
|
completed: 0,
|
||||||
|
categories: {
|
||||||
|
'Upper': 0,
|
||||||
|
'Lower': 0,
|
||||||
|
'Accessory': 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['Retro', '5.56 NATO', '20" Barrel'],
|
||||||
|
image: 'https://picsum.photos/400/250?random=6'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
|
||||||
|
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export default function MyBuildsPage() {
|
||||||
|
const [sortField, setSortField] = useState<SortField>('completedDate');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Filter builds
|
||||||
|
const filteredMyBuilds = sampleMyBuilds.filter(build => {
|
||||||
|
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm && !build.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||||
|
!build.description.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort builds
|
||||||
|
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
|
||||||
|
let aValue: any, bValue: any;
|
||||||
|
|
||||||
|
if (sortField === 'totalCost') {
|
||||||
|
aValue = a.totalCost;
|
||||||
|
bValue = b.totalCost;
|
||||||
|
} else if (sortField === 'status') {
|
||||||
|
aValue = a.status;
|
||||||
|
bValue = b.status;
|
||||||
|
} else if (sortField === 'completedDate') {
|
||||||
|
aValue = a.completedDate || a.startedDate || a.plannedDate || '';
|
||||||
|
bValue = b.completedDate || b.startedDate || b.plannedDate || '';
|
||||||
|
} else {
|
||||||
|
aValue = a.name.toLowerCase();
|
||||||
|
bValue = b.name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusColor = (status: MyBuildStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'in-progress':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'planning':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: MyBuildStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '✓';
|
||||||
|
case 'in-progress':
|
||||||
|
return '🔄';
|
||||||
|
case 'planning':
|
||||||
|
return '📋';
|
||||||
|
default:
|
||||||
|
return '❓';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
|
||||||
|
return Math.round((build.components.completed / build.components.total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalMyBuilds = sampleMyBuilds.length;
|
||||||
|
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
|
||||||
|
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
|
||||||
|
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50">
|
||||||
|
{/* Page Title */}
|
||||||
|
<div className="bg-white border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">My Builds</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Track and manage your firearm builds</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build Summary */}
|
||||||
|
<div className="bg-white border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
|
||||||
|
<div className="text-sm text-gray-500">Total Builds</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
|
||||||
|
<div className="text-sm text-gray-500">Completed</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
|
||||||
|
<div className="text-sm text-gray-500">In Progress</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-purple-600">${totalValue.toFixed(2)}</div>
|
||||||
|
<div className="text-sm text-gray-500">Total Value</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="bg-white border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
{/* Filters Row */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="in-progress">In Progress</option>
|
||||||
|
<option value="planning">Planning</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort by */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
|
||||||
|
<select
|
||||||
|
value={sortField}
|
||||||
|
onChange={(e) => setSortField(e.target.value as SortField)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="completedDate">Date</option>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="totalCost">Cost</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Direction */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Order</label>
|
||||||
|
<select
|
||||||
|
value={sortDirection}
|
||||||
|
onChange={(e) => setSortDirection(e.target.value as SortDirection)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="desc">Newest First</option>
|
||||||
|
<option value="asc">Oldest First</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Build Button */}
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
+ New Build
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Builds Grid */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{sortedMyBuilds.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{sortedMyBuilds.map((build) => (
|
||||||
|
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
|
||||||
|
{/* Build Image */}
|
||||||
|
<div className="h-48 bg-gray-200 relative">
|
||||||
|
<img
|
||||||
|
src={build.image}
|
||||||
|
alt={build.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
target.nextElementSibling?.classList.remove('hidden');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="hidden w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-300 to-gray-400">
|
||||||
|
<div className="text-center text-gray-600">
|
||||||
|
<div className="text-4xl mb-2">🔫</div>
|
||||||
|
<div className="text-sm font-medium">{build.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(build.status)}`}>
|
||||||
|
{getStatusIcon(build.status)} {build.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Build Title and Date */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">{build.name}</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">{build.description}</p>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{build.status === 'completed' && build.completedDate && `Completed ${formatDate(build.completedDate)}`}
|
||||||
|
{build.status === 'in-progress' && build.startedDate && `Started ${formatDate(build.startedDate)}`}
|
||||||
|
{build.status === 'planning' && build.plannedDate && `Planned for ${formatDate(build.plannedDate)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{build.components.completed}/{build.components.total} components</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${getProgressPercentage(build)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Component Categories */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{Object.entries(build.components.categories).map(([category, count]) => (
|
||||||
|
<span key={category} className="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100 text-gray-700">
|
||||||
|
{category}: {count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{build.tags.map((tag) => (
|
||||||
|
<span key={tag} className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost and Actions */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-lg font-bold text-gray-900">
|
||||||
|
${build.totalCost.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors">
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
<button className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-sm hover:bg-gray-200 transition-colors">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-500">
|
||||||
|
<div className="text-lg font-medium mb-2">No builds found</div>
|
||||||
|
<div className="text-sm">Try adjusting your filters or create a new build</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+53
-635
@@ -1,640 +1,58 @@
|
|||||||
'use client';
|
import BetaTester from "../components/BetaTester";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { Listbox, Transition } from '@headlessui/react';
|
|
||||||
import { ChevronUpDownIcon, CheckIcon, XMarkIcon } from '@heroicons/react/20/solid';
|
|
||||||
import SearchInput from '@/components/SearchInput';
|
|
||||||
import ProductCard from '@/components/ProductCard';
|
|
||||||
import RestrictionAlert from '@/components/RestrictionAlert';
|
|
||||||
import Tooltip from '@/components/Tooltip';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { mockProducts } from '@/mock/product';
|
|
||||||
import type { Product } from '@/mock/product';
|
|
||||||
|
|
||||||
// Extract unique values for dropdowns
|
|
||||||
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
|
|
||||||
const brands = ['All', ...Array.from(new Set(mockProducts.map(part => part.brand.name)))];
|
|
||||||
const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.offers.map(offer => offer.vendor.name))))];
|
|
||||||
|
|
||||||
// Restrictions for filter dropdown
|
|
||||||
const restrictionOptions = [
|
|
||||||
'',
|
|
||||||
'NFA',
|
|
||||||
'SBR',
|
|
||||||
'SUPPRESSOR',
|
|
||||||
'STATE_RESTRICTIONS',
|
|
||||||
];
|
|
||||||
|
|
||||||
type SortField = 'name' | 'category' | 'price';
|
|
||||||
type SortDirection = 'asc' | 'desc';
|
|
||||||
|
|
||||||
// Restriction indicator component
|
|
||||||
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
|
|
||||||
const restrictionConfig = {
|
|
||||||
NFA: {
|
|
||||||
label: 'NFA',
|
|
||||||
color: 'bg-red-600 text-white',
|
|
||||||
icon: '🔒',
|
|
||||||
tooltip: 'National Firearms Act - Requires special registration'
|
|
||||||
},
|
|
||||||
SBR: {
|
|
||||||
label: 'SBR',
|
|
||||||
color: 'bg-orange-600 text-white',
|
|
||||||
icon: '📏',
|
|
||||||
tooltip: 'Short Barrel Rifle - Requires NFA registration'
|
|
||||||
},
|
|
||||||
SUPPRESSOR: {
|
|
||||||
label: 'Suppressor',
|
|
||||||
color: 'bg-purple-600 text-white',
|
|
||||||
icon: '🔇',
|
|
||||||
tooltip: 'Sound Suppressor - Requires NFA registration'
|
|
||||||
},
|
|
||||||
FFL_REQUIRED: {
|
|
||||||
label: 'FFL',
|
|
||||||
color: 'bg-blue-600 text-white',
|
|
||||||
icon: '🏪',
|
|
||||||
tooltip: 'Federal Firearms License required for purchase'
|
|
||||||
},
|
|
||||||
STATE_RESTRICTIONS: {
|
|
||||||
label: 'State',
|
|
||||||
color: 'bg-yellow-600 text-black',
|
|
||||||
icon: '🗺️',
|
|
||||||
tooltip: 'State-specific restrictions may apply'
|
|
||||||
},
|
|
||||||
HIGH_CAPACITY: {
|
|
||||||
label: 'High Cap',
|
|
||||||
color: 'bg-pink-600 text-white',
|
|
||||||
icon: '🥁',
|
|
||||||
tooltip: 'High capacity magazine - check local laws'
|
|
||||||
},
|
|
||||||
SILENCERSHOP_PARTNER: {
|
|
||||||
label: 'SilencerShop',
|
|
||||||
color: 'bg-green-600 text-white',
|
|
||||||
icon: '🤝',
|
|
||||||
tooltip: 'Available through SilencerShop partnership'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="bg-white font-sans">
|
||||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`}
|
{/* SVG Grid Background */}
|
||||||
title={config.tooltip}
|
<div className="relative isolate pt-1">
|
||||||
>
|
<svg
|
||||||
<span>{config.icon}</span>
|
aria-hidden="true"
|
||||||
<span>{config.label}</span>
|
className="absolute inset-0 -z-10 w-full h-full stroke-gray-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id="grid"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<path d="M100 200V.5M.5 .5H200" fill="none" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect fill="url(#grid)" width="100%" height="100%" strokeWidth={0} />
|
||||||
|
</svg>
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:flex lg:items-start lg:gap-x-10 lg:px-8 lg:py-40">
|
||||||
|
{/* Left: Headline, Subheading, Button */}
|
||||||
|
<div className="mx-auto max-w-2xl lg:mx-0 lg:flex-auto">
|
||||||
|
<h1 className="mt-10 text-pretty text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl">
|
||||||
|
A better way to plan your next build
|
||||||
|
</h1>
|
||||||
|
<p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">
|
||||||
|
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
|
||||||
|
fugiat veniam occaecat fugiat aliqua. Anim aute id magna aliqua ad ad non deserunt sunt.
|
||||||
|
</p>
|
||||||
|
<div className="mt-10 flex items-top gap-x-6">
|
||||||
|
<Link
|
||||||
|
href="/build"
|
||||||
|
className="btn btn-primary text-base font-semibold px-6"
|
||||||
|
>
|
||||||
|
Get Building
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Right: Product Image */}
|
||||||
|
<div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center">
|
||||||
|
<img
|
||||||
|
alt="AR-15 Lower Receiver"
|
||||||
|
src="https://i.imgur.com/IK8FbaI.png"
|
||||||
|
className="max-w-md w-full h-auto object-contain rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Beta Tester CTA */}
|
||||||
|
<BetaTester />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
// Tailwind UI Dropdown Component
|
|
||||||
const Dropdown = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
placeholder = "Select option"
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
options: string[];
|
|
||||||
placeholder?: string;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<Listbox value={value} onChange={onChange}>
|
|
||||||
<div className="relative">
|
|
||||||
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
||||||
{label}
|
|
||||||
</Listbox.Label>
|
|
||||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
|
|
||||||
<span className="block truncate text-neutral-900 dark:text-white">
|
|
||||||
{value || placeholder}
|
|
||||||
</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronUpDownIcon
|
|
||||||
className="h-5 w-5 text-neutral-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Listbox.Button>
|
|
||||||
<Transition
|
|
||||||
as="div"
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
|
||||||
>
|
|
||||||
<Listbox.Options>
|
|
||||||
{options.map((option, optionIdx) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={optionIdx}
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
|
||||||
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
value={option}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
{option}
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
|
||||||
const [selectedBrand, setSelectedBrand] = useState('All');
|
|
||||||
const [selectedVendor, setSelectedVendor] = useState('All');
|
|
||||||
const [priceRange, setPriceRange] = useState('');
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [selectedRestriction, setSelectedRestriction] = useState('');
|
|
||||||
const [sortField, setSortField] = useState<SortField>('name');
|
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
|
||||||
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
|
||||||
|
|
||||||
// Read category from URL parameter on page load
|
|
||||||
useEffect(() => {
|
|
||||||
const categoryParam = searchParams.get('category');
|
|
||||||
if (categoryParam && categories.includes(categoryParam)) {
|
|
||||||
setSelectedCategory(categoryParam);
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Filter parts based on selected criteria
|
|
||||||
const filteredParts = mockProducts.filter(part => {
|
|
||||||
const matchesCategory = selectedCategory === 'All' || part.category.name === selectedCategory;
|
|
||||||
const matchesBrand = selectedBrand === 'All' || part.brand.name === selectedBrand;
|
|
||||||
const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor);
|
|
||||||
const matchesSearch = !searchTerm ||
|
|
||||||
part.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
part.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
part.brand.name.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
// Restriction filter logic
|
|
||||||
let matchesRestriction = true;
|
|
||||||
if (selectedRestriction) {
|
|
||||||
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
|
|
||||||
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
|
|
||||||
else if (selectedRestriction === 'SUPPRESSOR') matchesRestriction = !!part.restrictions?.suppressor;
|
|
||||||
else if (selectedRestriction === 'STATE_RESTRICTIONS') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
|
|
||||||
else matchesRestriction = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Price range filtering
|
|
||||||
let matchesPrice = true;
|
|
||||||
if (priceRange) {
|
|
||||||
const lowestPrice = Math.min(...part.offers.map(offer => offer.price));
|
|
||||||
switch (priceRange) {
|
|
||||||
case 'under-100':
|
|
||||||
matchesPrice = lowestPrice < 100;
|
|
||||||
break;
|
|
||||||
case '100-300':
|
|
||||||
matchesPrice = lowestPrice >= 100 && lowestPrice <= 300;
|
|
||||||
break;
|
|
||||||
case '300-500':
|
|
||||||
matchesPrice = lowestPrice > 300 && lowestPrice <= 500;
|
|
||||||
break;
|
|
||||||
case 'over-500':
|
|
||||||
matchesPrice = lowestPrice > 500;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchesCategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort parts
|
|
||||||
const sortedParts = [...filteredParts].sort((a, b) => {
|
|
||||||
let aValue: any, bValue: any;
|
|
||||||
|
|
||||||
if (sortField === 'price') {
|
|
||||||
aValue = Math.min(...a.offers.map(offer => offer.price));
|
|
||||||
bValue = Math.min(...b.offers.map(offer => offer.price));
|
|
||||||
} else if (sortField === 'category') {
|
|
||||||
aValue = a.category.name.toLowerCase();
|
|
||||||
bValue = b.category.name.toLowerCase();
|
|
||||||
} else {
|
|
||||||
aValue = a.name.toLowerCase();
|
|
||||||
bValue = b.name.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortDirection === 'asc') {
|
|
||||||
return aValue > bValue ? 1 : -1;
|
|
||||||
} else {
|
|
||||||
return aValue < bValue ? 1 : -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
if (sortField === field) {
|
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortDirection('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSortIcon = (field: SortField) => {
|
|
||||||
if (sortField !== field) {
|
|
||||||
return '↕️';
|
|
||||||
}
|
|
||||||
return sortDirection === 'asc' ? '↑' : '↓';
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setSelectedCategory('All');
|
|
||||||
setSelectedBrand('All');
|
|
||||||
setSelectedVendor('All');
|
|
||||||
setSearchTerm('');
|
|
||||||
setPriceRange('');
|
|
||||||
setSelectedRestriction('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasActiveFilters = selectedCategory !== 'All' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction;
|
|
||||||
|
|
||||||
// RestrictionBadge for table view (show NFA/SBR/Suppressor/State)
|
|
||||||
const getRestrictionFlags = (restrictions?: Product['restrictions']) => {
|
|
||||||
const flags: string[] = [];
|
|
||||||
if (restrictions?.nfa) flags.push('NFA');
|
|
||||||
if (restrictions?.sbr) flags.push('SBR');
|
|
||||||
if (restrictions?.suppressor) flags.push('SUPPRESSOR');
|
|
||||||
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('STATE_RESTRICTIONS');
|
|
||||||
return flags;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
|
|
||||||
{/* Page Title */}
|
|
||||||
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">
|
|
||||||
Parts Catalog
|
|
||||||
{selectedCategory !== 'All' && (
|
|
||||||
<span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl">
|
|
||||||
- {selectedCategory}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h1>
|
|
||||||
<p className="text-neutral-600 dark:text-neutral-400 mt-2">
|
|
||||||
{selectedCategory !== 'All'
|
|
||||||
? `Showing ${selectedCategory} parts for your build`
|
|
||||||
: 'Browse and filter firearm parts for your build'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search and Filters */}
|
|
||||||
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
||||||
{/* Search Row */}
|
|
||||||
<div className="mb-4 flex justify-end">
|
|
||||||
<div className="w-1/2">
|
|
||||||
<SearchInput
|
|
||||||
label="Search"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={setSearchTerm}
|
|
||||||
placeholder="Search parts..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters Row */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
|
|
||||||
{/* Category Dropdown */}
|
|
||||||
<Dropdown
|
|
||||||
label="Category"
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={setSelectedCategory}
|
|
||||||
options={categories}
|
|
||||||
placeholder="All categories"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Brand Dropdown */}
|
|
||||||
<Dropdown
|
|
||||||
label="Brand"
|
|
||||||
value={selectedBrand}
|
|
||||||
onChange={setSelectedBrand}
|
|
||||||
options={brands}
|
|
||||||
placeholder="All brands"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Vendor Dropdown */}
|
|
||||||
<Dropdown
|
|
||||||
label="Vendor"
|
|
||||||
value={selectedVendor}
|
|
||||||
onChange={setSelectedVendor}
|
|
||||||
options={vendors}
|
|
||||||
placeholder="All vendors"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Price Range */}
|
|
||||||
<div className="relative">
|
|
||||||
<Listbox value={priceRange} onChange={setPriceRange}>
|
|
||||||
<div className="relative">
|
|
||||||
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
||||||
Price Range
|
|
||||||
</Listbox.Label>
|
|
||||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
|
|
||||||
<span className="block truncate text-neutral-900 dark:text-white">
|
|
||||||
{priceRange === '' ? 'Select price range' :
|
|
||||||
priceRange === 'under-100' ? 'Under $100' :
|
|
||||||
priceRange === '100-300' ? '$100 - $300' :
|
|
||||||
priceRange === '300-500' ? '$300 - $500' :
|
|
||||||
priceRange === 'over-500' ? '$500+' : priceRange}
|
|
||||||
</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronUpDownIcon
|
|
||||||
className="h-5 w-5 text-neutral-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Listbox.Button>
|
|
||||||
<Transition
|
|
||||||
as="div"
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
|
||||||
>
|
|
||||||
<Listbox.Options>
|
|
||||||
{[
|
|
||||||
{ value: '', label: 'Select price range' },
|
|
||||||
{ value: 'under-100', label: 'Under $100' },
|
|
||||||
{ value: '100-300', label: '$100 - $300' },
|
|
||||||
{ value: '300-500', label: '$300 - $500' },
|
|
||||||
{ value: 'over-500', label: '$500+' }
|
|
||||||
].map((option, optionIdx) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={optionIdx}
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
|
||||||
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Restriction Filter */}
|
|
||||||
<Dropdown
|
|
||||||
label="Restriction"
|
|
||||||
value={selectedRestriction}
|
|
||||||
onChange={setSelectedRestriction}
|
|
||||||
options={restrictionOptions}
|
|
||||||
placeholder="All restrictions"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Clear Filters */}
|
|
||||||
<div className="flex items-end">
|
|
||||||
<button
|
|
||||||
onClick={clearFilters}
|
|
||||||
disabled={!hasActiveFilters}
|
|
||||||
className={`w-full px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2 ${
|
|
||||||
hasActiveFilters
|
|
||||||
? 'bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white'
|
|
||||||
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-400 dark:text-neutral-500 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-4 w-4" />
|
|
||||||
Clear All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parts Display */}
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
{/* View Toggle and Results Count */}
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Showing {sortedParts.length} of {mockProducts.length} parts
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
|
||||||
(filtered)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View Toggle */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">View:</span>
|
|
||||||
<div className="btn-group">
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
|
|
||||||
onClick={() => setViewMode('table')}
|
|
||||||
>
|
|
||||||
Table
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
|
|
||||||
onClick={() => setViewMode('cards')}
|
|
||||||
>
|
|
||||||
Cards
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Restriction Alert Example */}
|
|
||||||
{sortedParts.some(part => part.restrictions?.nfa) && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<RestrictionAlert
|
|
||||||
type="warning"
|
|
||||||
title="NFA Items Detected"
|
|
||||||
message="Some items in your search require National Firearms Act registration. Please ensure compliance with all federal and state regulations."
|
|
||||||
icon="🔒"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table View */}
|
|
||||||
{viewMode === 'table' && (
|
|
||||||
<div className="bg-white dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
|
|
||||||
<thead className="bg-neutral-50 dark:bg-neutral-700">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
|
||||||
Category
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
|
|
||||||
onClick={() => handleSort('name')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<span>Name</span>
|
|
||||||
<span className="text-sm">{getSortIcon('name')}</span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
|
||||||
Brand
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
|
||||||
Description
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
|
|
||||||
onClick={() => handleSort('price')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<span>Price</span>
|
|
||||||
<span className="text-sm">{getSortIcon('price')}</span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
|
||||||
{sortedParts.map((part) => (
|
|
||||||
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
|
|
||||||
{part.category.name}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-neutral-900 dark:text-white">
|
|
||||||
{part.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
||||||
{part.brand.name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-neutral-900 dark:text-white">
|
|
||||||
{part.brand.name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
||||||
{part.description}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
|
|
||||||
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
||||||
<Link href={`/products/${part.id}`} legacyBehavior>
|
|
||||||
<a className="btn btn-primary btn-sm">
|
|
||||||
View Details
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table Footer */}
|
|
||||||
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Showing {sortedParts.length} of {mockProducts.length} parts
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
|
||||||
(filtered)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
||||||
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Card View */}
|
|
||||||
{viewMode === 'cards' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
{sortedParts.map((part) => (
|
|
||||||
<ProductCard key={part.id} product={part} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Compact Restriction Legend */}
|
|
||||||
<div className="mt-8 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
|
||||||
<div className="flex items-center justify-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
|
|
||||||
<span className="font-medium">Restrictions:</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-600 text-white">🔒NFA</div>
|
|
||||||
<span>National Firearms Act</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-600 text-white">📏SBR</div>
|
|
||||||
<span>Short Barrel Rifle</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-purple-600 text-white">🔇Suppressor</div>
|
|
||||||
<span>Sound Suppressor</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-600 text-white">🏪FFL</div>
|
|
||||||
<span>FFL Required</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-600 text-black">🗺️State</div>
|
|
||||||
<span>State Restrictions</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-600 text-white">🥁High Cap</div>
|
|
||||||
<span>High Capacity</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-green-600 text-white">🤝SilencerShop</div>
|
|
||||||
<span>SilencerShop Partner</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,766 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { Listbox, Transition } from '@headlessui/react';
|
||||||
|
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
||||||
|
import SearchInput from '@/components/SearchInput';
|
||||||
|
import ProductCard from '@/components/ProductCard';
|
||||||
|
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||||
|
import Tooltip from '@/components/Tooltip';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { mockProducts } from '@/mock/product';
|
||||||
|
import type { Product } from '@/mock/product';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useBuildStore } from '@/store/useBuildStore';
|
||||||
|
import { buildGroups } from '../build/page';
|
||||||
|
|
||||||
|
// Extract unique values for dropdowns
|
||||||
|
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
|
||||||
|
const brands = ['All', ...Array.from(new Set(mockProducts.map(part => part.brand.name)))];
|
||||||
|
const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.offers.map(offer => offer.vendor.name))))];
|
||||||
|
|
||||||
|
// Restrictions for filter dropdown
|
||||||
|
const restrictionOptions = [
|
||||||
|
'All',
|
||||||
|
'NFA',
|
||||||
|
'SBR',
|
||||||
|
'Suppressor',
|
||||||
|
'State Restrictions',
|
||||||
|
];
|
||||||
|
|
||||||
|
type SortField = 'name' | 'category' | 'price';
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
// Restriction indicator component
|
||||||
|
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
|
||||||
|
const restrictionConfig = {
|
||||||
|
NFA: {
|
||||||
|
label: 'NFA',
|
||||||
|
color: 'bg-red-600 text-white',
|
||||||
|
icon: '🔒',
|
||||||
|
tooltip: 'National Firearms Act - Requires special registration'
|
||||||
|
},
|
||||||
|
SBR: {
|
||||||
|
label: 'SBR',
|
||||||
|
color: 'bg-orange-600 text-white',
|
||||||
|
icon: '📏',
|
||||||
|
tooltip: 'Short Barrel Rifle - Requires NFA registration'
|
||||||
|
},
|
||||||
|
SUPPRESSOR: {
|
||||||
|
label: 'Suppressor',
|
||||||
|
color: 'bg-purple-600 text-white',
|
||||||
|
icon: '🔇',
|
||||||
|
tooltip: 'Sound Suppressor - Requires NFA registration'
|
||||||
|
},
|
||||||
|
FFL_REQUIRED: {
|
||||||
|
label: 'FFL',
|
||||||
|
color: 'bg-blue-600 text-white',
|
||||||
|
icon: '🏪',
|
||||||
|
tooltip: 'Federal Firearms License required for purchase'
|
||||||
|
},
|
||||||
|
STATE_RESTRICTIONS: {
|
||||||
|
label: 'State',
|
||||||
|
color: 'bg-yellow-600 text-black',
|
||||||
|
icon: '🗺️',
|
||||||
|
tooltip: 'State-specific restrictions may apply'
|
||||||
|
},
|
||||||
|
HIGH_CAPACITY: {
|
||||||
|
label: 'High Cap',
|
||||||
|
color: 'bg-pink-600 text-white',
|
||||||
|
icon: '🥁',
|
||||||
|
tooltip: 'High capacity magazine - check local laws'
|
||||||
|
},
|
||||||
|
SILENCERSHOP_PARTNER: {
|
||||||
|
label: 'SilencerShop',
|
||||||
|
color: 'bg-green-600 text-white',
|
||||||
|
icon: '🤝',
|
||||||
|
tooltip: 'Available through SilencerShop partnership'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`}
|
||||||
|
title={config.tooltip}
|
||||||
|
>
|
||||||
|
<span>{config.icon}</span>
|
||||||
|
<span>{config.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tailwind UI Dropdown Component
|
||||||
|
const Dropdown = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = "Select option"
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Listbox value={value} onChange={onChange}>
|
||||||
|
<div className="relative">
|
||||||
|
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{label}
|
||||||
|
</Listbox.Label>
|
||||||
|
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
|
||||||
|
<span className="block truncate text-neutral-900 dark:text-white">
|
||||||
|
{value || placeholder}
|
||||||
|
</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon
|
||||||
|
className="h-4 w-4 text-neutral-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Listbox.Button>
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<Listbox.Options>
|
||||||
|
{options.map((option, optionIdx) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={optionIdx}
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||||
|
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
value={option}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
{option}
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
||||||
|
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox.Option>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map product categories to checklist component categories
|
||||||
|
const getComponentCategory = (productCategory: string): string => {
|
||||||
|
const categoryMap: Record<string, string> = {
|
||||||
|
// Upper components
|
||||||
|
'Upper Receiver': 'Upper',
|
||||||
|
'Barrel': 'Upper',
|
||||||
|
'BCG': 'Upper',
|
||||||
|
'Bolt Carrier Group': 'Upper',
|
||||||
|
'Charging Handle': 'Upper',
|
||||||
|
'Gas Block': 'Upper',
|
||||||
|
'Gas Tube': 'Upper',
|
||||||
|
'Handguard': 'Upper',
|
||||||
|
'Muzzle Device': 'Upper',
|
||||||
|
'Suppressor': 'Upper',
|
||||||
|
|
||||||
|
// Lower components
|
||||||
|
'Lower Receiver': 'Lower',
|
||||||
|
'Trigger': 'Lower',
|
||||||
|
'Trigger Guard': 'Lower',
|
||||||
|
'Pistol Grip': 'Lower',
|
||||||
|
'Buffer Tube': 'Lower',
|
||||||
|
'Buffer': 'Lower',
|
||||||
|
'Buffer Spring': 'Lower',
|
||||||
|
'Stock': 'Lower',
|
||||||
|
|
||||||
|
// Accessories
|
||||||
|
'Magazine': 'Accessory',
|
||||||
|
'Sights': 'Accessory',
|
||||||
|
'Optic': 'Accessory',
|
||||||
|
'Scope': 'Accessory',
|
||||||
|
'Red Dot': 'Accessory',
|
||||||
|
};
|
||||||
|
|
||||||
|
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map product categories to specific checklist component names
|
||||||
|
const getMatchingComponentName = (productCategory: string): string => {
|
||||||
|
const componentMap: Record<string, string> = {
|
||||||
|
'Upper Receiver': 'Upper Receiver',
|
||||||
|
'Barrel': 'Barrel',
|
||||||
|
'BCG': 'Bolt Carrier Group (BCG)',
|
||||||
|
'Bolt Carrier Group': 'Bolt Carrier Group (BCG)',
|
||||||
|
'Charging Handle': 'Charging Handle',
|
||||||
|
'Gas Block': 'Gas Block',
|
||||||
|
'Gas Tube': 'Gas Tube',
|
||||||
|
'Handguard': 'Handguard',
|
||||||
|
'Muzzle Device': 'Muzzle Device',
|
||||||
|
'Suppressor': 'Muzzle Device', // Suppressors go to Muzzle Device component
|
||||||
|
|
||||||
|
'Lower Receiver': 'Lower Receiver',
|
||||||
|
'Trigger': 'Trigger',
|
||||||
|
'Trigger Guard': 'Trigger Guard',
|
||||||
|
'Pistol Grip': 'Pistol Grip',
|
||||||
|
'Buffer Tube': 'Buffer Tube',
|
||||||
|
'Buffer': 'Buffer',
|
||||||
|
'Buffer Spring': 'Buffer Spring',
|
||||||
|
'Stock': 'Stock',
|
||||||
|
|
||||||
|
'Magazine': 'Magazine',
|
||||||
|
'Sights': 'Sights',
|
||||||
|
'Optic': 'Sights',
|
||||||
|
'Scope': 'Sights',
|
||||||
|
'Red Dot': 'Sights',
|
||||||
|
};
|
||||||
|
|
||||||
|
return componentMap[productCategory] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||||
|
const [selectedBrand, setSelectedBrand] = useState('All');
|
||||||
|
const [selectedVendor, setSelectedVendor] = useState('All');
|
||||||
|
const [priceRange, setPriceRange] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedRestriction, setSelectedRestriction] = useState('');
|
||||||
|
const [sortField, setSortField] = useState<SortField>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
||||||
|
const [addedPartIds, setAddedPartIds] = useState<string[]>([]);
|
||||||
|
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||||
|
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
|
||||||
|
const selectedParts = useBuildStore((state) => state.selectedParts);
|
||||||
|
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
|
||||||
|
|
||||||
|
// Read category from URL parameter on page load
|
||||||
|
useEffect(() => {
|
||||||
|
const categoryParam = searchParams.get('category');
|
||||||
|
if (categoryParam && categories.includes(categoryParam)) {
|
||||||
|
setSelectedCategory(categoryParam);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Filter parts based on selected criteria
|
||||||
|
const filteredParts = mockProducts.filter(part => {
|
||||||
|
const matchesCategory = selectedCategory === 'All' || part.category.name === selectedCategory;
|
||||||
|
const matchesBrand = selectedBrand === 'All' || part.brand.name === selectedBrand;
|
||||||
|
const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor);
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
part.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
part.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
part.brand.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
// Restriction filter logic
|
||||||
|
let matchesRestriction = true;
|
||||||
|
if (selectedRestriction) {
|
||||||
|
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
|
||||||
|
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
|
||||||
|
else if (selectedRestriction === 'Suppressor') matchesRestriction = !!part.restrictions?.suppressor;
|
||||||
|
else if (selectedRestriction === 'State Restrictions') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
|
||||||
|
else matchesRestriction = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price range filtering
|
||||||
|
let matchesPrice = true;
|
||||||
|
if (priceRange) {
|
||||||
|
const lowestPrice = Math.min(...part.offers.map(offer => offer.price));
|
||||||
|
switch (priceRange) {
|
||||||
|
case 'under-100':
|
||||||
|
matchesPrice = lowestPrice < 100;
|
||||||
|
break;
|
||||||
|
case '100-300':
|
||||||
|
matchesPrice = lowestPrice >= 100 && lowestPrice <= 300;
|
||||||
|
break;
|
||||||
|
case '300-500':
|
||||||
|
matchesPrice = lowestPrice > 300 && lowestPrice <= 500;
|
||||||
|
break;
|
||||||
|
case 'over-500':
|
||||||
|
matchesPrice = lowestPrice > 500;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchesCategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort parts
|
||||||
|
const sortedParts = [...filteredParts].sort((a, b) => {
|
||||||
|
let aValue: any, bValue: any;
|
||||||
|
|
||||||
|
if (sortField === 'price') {
|
||||||
|
aValue = Math.min(...a.offers.map(offer => offer.price));
|
||||||
|
bValue = Math.min(...b.offers.map(offer => offer.price));
|
||||||
|
} else if (sortField === 'category') {
|
||||||
|
aValue = a.category.name.toLowerCase();
|
||||||
|
bValue = b.category.name.toLowerCase();
|
||||||
|
} else {
|
||||||
|
aValue = a.name.toLowerCase();
|
||||||
|
bValue = b.name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field: SortField) => {
|
||||||
|
if (sortField !== field) {
|
||||||
|
return '↕️';
|
||||||
|
}
|
||||||
|
return sortDirection === 'asc' ? '↑' : '↓';
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSelectedCategory('All');
|
||||||
|
setSelectedBrand('All');
|
||||||
|
setSelectedVendor('All');
|
||||||
|
setSearchTerm('');
|
||||||
|
setPriceRange('');
|
||||||
|
setSelectedRestriction('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = selectedCategory !== 'All' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction;
|
||||||
|
|
||||||
|
// RestrictionBadge for table view (show NFA/SBR/Suppressor/State)
|
||||||
|
const getRestrictionFlags = (restrictions?: Product['restrictions']) => {
|
||||||
|
const flags: string[] = [];
|
||||||
|
if (restrictions?.nfa) flags.push('NFA');
|
||||||
|
if (restrictions?.sbr) flags.push('SBR');
|
||||||
|
if (restrictions?.suppressor) flags.push('Suppressor');
|
||||||
|
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('State Restrictions');
|
||||||
|
return flags;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = (part: Product) => {
|
||||||
|
setAddedPartIds((prev) => [...prev, part.id]);
|
||||||
|
setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
|
||||||
|
{/* Page Title */}
|
||||||
|
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Parts Catalog
|
||||||
|
{selectedCategory !== 'All' && (
|
||||||
|
<span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl">
|
||||||
|
- {selectedCategory}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400 mt-2">
|
||||||
|
{selectedCategory !== 'All'
|
||||||
|
? `Showing ${selectedCategory} parts for your build`
|
||||||
|
: 'Browse and filter firearm parts for your build'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||||
|
{/* Search Row */}
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<div className={`transition-all duration-300 ease-in-out flex justify-end ${isSearchExpanded ? 'w-1/2' : 'w-auto'}`}>
|
||||||
|
{isSearchExpanded ? (
|
||||||
|
<div className="flex items-center gap-2 w-full justify-end">
|
||||||
|
<div className="flex-1 max-w-md">
|
||||||
|
<SearchInput
|
||||||
|
label=""
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={setSearchTerm}
|
||||||
|
placeholder="Search parts..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsSearchExpanded(false);
|
||||||
|
setSearchTerm('');
|
||||||
|
}}
|
||||||
|
className="p-2 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors flex-shrink-0"
|
||||||
|
aria-label="Close search"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSearchExpanded(true)}
|
||||||
|
className="p-2 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||||
|
aria-label="Open search"
|
||||||
|
>
|
||||||
|
<MagnifyingGlassIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Row */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-6 lg:grid-cols-7 gap-3">
|
||||||
|
{/* Category Dropdown */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Dropdown
|
||||||
|
label="Category"
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={setSelectedCategory}
|
||||||
|
options={categories}
|
||||||
|
placeholder="All categories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brand Dropdown */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Dropdown
|
||||||
|
label="Brand"
|
||||||
|
value={selectedBrand}
|
||||||
|
onChange={setSelectedBrand}
|
||||||
|
options={brands}
|
||||||
|
placeholder="All brands"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vendor Dropdown */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Dropdown
|
||||||
|
label="Vendor"
|
||||||
|
value={selectedVendor}
|
||||||
|
onChange={setSelectedVendor}
|
||||||
|
options={vendors}
|
||||||
|
placeholder="All vendors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price Range */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Listbox value={priceRange} onChange={setPriceRange}>
|
||||||
|
<div className="relative">
|
||||||
|
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
Price Range
|
||||||
|
</Listbox.Label>
|
||||||
|
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
|
||||||
|
<span className="block truncate text-neutral-900 dark:text-white">
|
||||||
|
{priceRange === '' ? 'Select price range' :
|
||||||
|
priceRange === 'under-100' ? 'Under $100' :
|
||||||
|
priceRange === '100-300' ? '$100 - $300' :
|
||||||
|
priceRange === '300-500' ? '$300 - $500' :
|
||||||
|
priceRange === 'over-500' ? '$500+' : priceRange}
|
||||||
|
</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon
|
||||||
|
className="h-4 w-4 text-neutral-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Listbox.Button>
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<Listbox.Options>
|
||||||
|
{[
|
||||||
|
{ value: '', label: 'Select price range' },
|
||||||
|
{ value: 'under-100', label: 'Under $100' },
|
||||||
|
{ value: '100-300', label: '$100 - $300' },
|
||||||
|
{ value: '300-500', label: '$300 - $500' },
|
||||||
|
{ value: 'over-500', label: '$500+' }
|
||||||
|
].map((option, optionIdx) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={optionIdx}
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||||
|
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
||||||
|
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox.Option>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Restriction Filter */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Dropdown
|
||||||
|
label="Restriction"
|
||||||
|
value={selectedRestriction}
|
||||||
|
onChange={setSelectedRestriction}
|
||||||
|
options={restrictionOptions}
|
||||||
|
placeholder="All restrictions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
<div className="col-span-1 flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
disabled={!hasActiveFilters}
|
||||||
|
className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${
|
||||||
|
hasActiveFilters
|
||||||
|
? 'bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white'
|
||||||
|
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-400 dark:text-neutral-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3.5 w-3.5" />
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parts Display */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* View Toggle and Results Count */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Showing {sortedParts.length} of {mockProducts.length} parts
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
||||||
|
(filtered)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">View:</span>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
aria-label="Table view"
|
||||||
|
>
|
||||||
|
<TableCellsIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
|
||||||
|
onClick={() => setViewMode('cards')}
|
||||||
|
aria-label="Card view"
|
||||||
|
>
|
||||||
|
<Squares2X2Icon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table View */}
|
||||||
|
{viewMode === 'table' && (
|
||||||
|
<div className="bg-white dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="overflow-x-auto max-h-screen overflow-y-auto">
|
||||||
|
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
|
<thead className="bg-neutral-50 dark:bg-neutral-700 sticky top-0 z-10 shadow-sm">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||||
|
Product
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||||
|
Category
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
|
||||||
|
onClick={() => handleSort('price')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>Price</span>
|
||||||
|
<span className="text-sm">{getSortIcon('price')}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
|
{sortedParts.map((part) => (
|
||||||
|
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap flex items-center gap-3 min-w-[180px]">
|
||||||
|
<div className="w-12 h-12 flex-shrink-0 rounded bg-neutral-100 dark:bg-neutral-700 overflow-hidden flex items-center justify-center border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<Image src={Array.isArray(part.images) && (part.images as string[]).length > 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link href={`/products/${part.id}`} className="text-sm font-semibold text-primary hover:underline dark:text-primary-400">
|
||||||
|
{part.name}
|
||||||
|
</Link>
|
||||||
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
|
||||||
|
{part.category.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
|
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
{(() => {
|
||||||
|
// Find if this part is already selected for any component
|
||||||
|
const selectedComponentId = Object.entries(selectedParts).find(([_, selectedPart]) => selectedPart?.id === part.id)?.[0];
|
||||||
|
|
||||||
|
// Find the appropriate component based on category match
|
||||||
|
const matchingComponentName = getMatchingComponentName(part.category.name);
|
||||||
|
const matchingComponent = (buildGroups as {components: any[]}[]).flatMap((group) => group.components).find((component: any) =>
|
||||||
|
component.name === matchingComponentName && !selectedParts[component.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedComponentId) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => removePartForComponent(selectedComponentId)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else if (matchingComponent && !selectedParts[matchingComponent.id]) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-neutral btn-sm flex items-center gap-1"
|
||||||
|
onClick={() => {
|
||||||
|
selectPartForComponent(matchingComponent.id, {
|
||||||
|
id: part.id,
|
||||||
|
name: part.name,
|
||||||
|
image_url: part.image_url,
|
||||||
|
brand: part.brand,
|
||||||
|
category: part.category,
|
||||||
|
offers: part.offers,
|
||||||
|
});
|
||||||
|
router.push('/build');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none">+</span>
|
||||||
|
<span className="text-xs font-normal">to build</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-gray-400">Part Selected</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Footer */}
|
||||||
|
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Showing {sortedParts.length} of {mockProducts.length} parts
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
||||||
|
(filtered)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card View */}
|
||||||
|
{viewMode === 'cards' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{sortedParts.map((part) => (
|
||||||
|
<ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact Restriction Legend */}
|
||||||
|
<div className="mt-8 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="flex items-center justify-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
<span className="font-medium">Restrictions:</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-600 text-white">🔒NFA</div>
|
||||||
|
<span>National Firearms Act</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-600 text-white">📏SBR</div>
|
||||||
|
<span>Short Barrel Rifle</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-purple-600 text-white">🔇Suppressor</div>
|
||||||
|
<span>Sound Suppressor</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-600 text-white">🏪FFL</div>
|
||||||
|
<span>FFL Required</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-600 text-black">🗺️State</div>
|
||||||
|
<span>State Restrictions</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-600 text-white">🥁High Cap</div>
|
||||||
|
<span>High Capacity</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-green-600 text-white">🤝SilencerShop</div>
|
||||||
|
<span>SilencerShop Partner</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { mockProducts } from '@/mock/product';
|
|||||||
import RestrictionAlert from '@/components/RestrictionAlert';
|
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||||
import { StarIcon } from '@heroicons/react/20/solid';
|
import { StarIcon } from '@heroicons/react/20/solid';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useBuildStore } from '@/store/useBuildStore';
|
||||||
|
|
||||||
export default function ProductDetailsPage() {
|
export default function ProductDetailsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -14,6 +15,8 @@ export default function ProductDetailsPage() {
|
|||||||
const product = mockProducts.find(p => p.id === productId);
|
const product = mockProducts.find(p => p.id === productId);
|
||||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||||
const [selectedOffer, setSelectedOffer] = useState(0);
|
const [selectedOffer, setSelectedOffer] = useState(0);
|
||||||
|
const [addSuccess, setAddSuccess] = useState(false);
|
||||||
|
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
@@ -35,14 +38,51 @@ export default function ProductDetailsPage() {
|
|||||||
? product.reviews.reduce((acc, review) => acc + review.rating, 0) / product.reviews.length
|
? product.reviews.reduce((acc, review) => acc + review.rating, 0) / product.reviews.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const handleAddToBuild = () => {
|
||||||
|
// Map category to component ID
|
||||||
|
const categoryToComponentMap: Record<string, string> = {
|
||||||
|
'Barrel': 'barrel',
|
||||||
|
'Upper Receiver': 'upper',
|
||||||
|
'Suppressor': 'suppressor',
|
||||||
|
'BCG': 'bcg',
|
||||||
|
'Charging Handle': 'charging-handle',
|
||||||
|
'Handguard': 'handguard',
|
||||||
|
'Gas Block': 'gas-block',
|
||||||
|
'Gas Tube': 'gas-tube',
|
||||||
|
'Muzzle Device': 'muzzle-device',
|
||||||
|
'Lower Receiver': 'lower',
|
||||||
|
'Trigger': 'trigger',
|
||||||
|
'Pistol Grip': 'pistol-grip',
|
||||||
|
'Buffer Tube': 'buffer-tube',
|
||||||
|
'Buffer': 'buffer',
|
||||||
|
'Buffer Spring': 'buffer-spring',
|
||||||
|
'Stock': 'stock',
|
||||||
|
'Magazine': 'magazine',
|
||||||
|
'Sights': 'sights'
|
||||||
|
};
|
||||||
|
|
||||||
|
const componentId = categoryToComponentMap[product.category.name] || product.category.id;
|
||||||
|
|
||||||
|
selectPartForComponent(componentId, {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
image_url: product.image_url,
|
||||||
|
brand: product.brand,
|
||||||
|
category: product.category,
|
||||||
|
offers: product.offers
|
||||||
|
});
|
||||||
|
setAddSuccess(true);
|
||||||
|
setTimeout(() => setAddSuccess(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="text-sm breadcrumbs mb-6">
|
<div className="text-sm breadcrumbs mb-6">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/">Home</a></li>
|
<li><a href="/">Home</a></li>
|
||||||
<li><a href="/products">Products</a></li>
|
<li><a href="/parts">Parts</a></li>
|
||||||
<li><a href={`/products?category=${product.category.id}`}>{product.category.name}</a></li>
|
<li><a href={`/parts?category=${product.category.name}`}>{product.category.name}</a></li>
|
||||||
<li>{product.name}</li>
|
<li>{product.name}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,11 +152,8 @@ export default function ProductDetailsPage() {
|
|||||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{product.brand.name}
|
{product.brand.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
<span className="text-2xl">{product.category.icon}</span>
|
{product.category.name}
|
||||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
|
||||||
{product.category.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,13 +209,16 @@ export default function ProductDetailsPage() {
|
|||||||
|
|
||||||
{/* Add to Build Button */}
|
{/* Add to Build Button */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button className="btn btn-primary flex-1">
|
<button className="btn btn-primary flex-1" onClick={handleAddToBuild}>
|
||||||
Add to Current Build
|
Add to Current Build
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-outline">
|
<button className="btn btn-outline">
|
||||||
Save for Later
|
Save for Later
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{addSuccess && (
|
||||||
|
<div className="mt-2 text-green-600 font-medium">Added to build!</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { useAuthStore } from '@/store/useAuthStore';
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const { setSession, setLoading } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSession(session);
|
||||||
|
setLoading(status === 'loading');
|
||||||
|
}, [session, status, setSession, setLoading]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export default function BetaTester() {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 py-16 sm:py-24 lg:py-32">
|
||||||
|
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-10 px-6 lg:grid-cols-12 lg:gap-8 lg:px-8">
|
||||||
|
<h2 className="max-w-xl text-balance text-3xl font-semibold tracking-tight text-white sm:text-4xl lg:col-span-7">
|
||||||
|
Interested in being a beta tester? Join the beta tester list.
|
||||||
|
</h2>
|
||||||
|
<form className="w-full max-w-md lg:col-span-5 lg:pt-2">
|
||||||
|
<div className="flex gap-x-4">
|
||||||
|
<label htmlFor="email-address" className="sr-only">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email-address"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="Enter your email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="min-w-0 flex-auto rounded-md bg-white/5 px-3.5 py-2 text-base text-white outline outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-none rounded-md bg-primary px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||||
|
>
|
||||||
|
Join The List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm/6 text-gray-300">
|
||||||
|
We care about your data. Read our{' '}
|
||||||
|
<a href="#" className="font-semibold text-white">
|
||||||
|
privacy policy
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+139
-55
@@ -3,42 +3,153 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import ThemeSwitcher from './ThemeSwitcher';
|
import ThemeSwitcher from './ThemeSwitcher';
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useSession, signIn, signOut } from 'next-auth/react';
|
||||||
|
import { useState, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
active?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
custom?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const loading = status === 'loading';
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/', label: 'Parts Catalog' },
|
{ href: '/parts', label: 'Parts Catalog' },
|
||||||
{ href: '/build', label: 'Build Checklist' },
|
{ href: '/build', label: 'Plan a Build' },
|
||||||
{ href: '/builds', label: 'My Builds' },
|
{ href: '/my-builds', label: 'My Builds' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
// Dropdown menu items
|
||||||
<nav className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700 shadow-sm">
|
const rawMenuItems = [
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
session?.user
|
||||||
<div className="flex justify-between items-center h-16">
|
? {
|
||||||
{/* Logo */}
|
label: 'Profile',
|
||||||
<div className="flex items-center">
|
href: '/account/profile',
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
active: pathname === '/account/profile',
|
||||||
<div className="w-8 h-8 bg-primary-600 dark:bg-primary-500 rounded-lg flex items-center justify-center">
|
onClick: () => setMenuOpen(false),
|
||||||
<span className="text-white font-bold text-lg">🔫</span>
|
}
|
||||||
</div>
|
: undefined,
|
||||||
<span className="text-xl font-bold text-neutral-900 dark:text-white">
|
session?.user
|
||||||
Pew Builder
|
? {
|
||||||
</span>
|
label: 'Sign Out',
|
||||||
</Link>
|
onClick: () => {
|
||||||
</div>
|
setMenuOpen(false);
|
||||||
|
signOut({ callbackUrl: '/' });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: 'Sign In',
|
||||||
|
onClick: () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
signIn();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Theme',
|
||||||
|
custom: <ThemeSwitcher />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
function isMenuItem(item: unknown): item is MenuItem {
|
||||||
|
return typeof item === 'object' && item !== null && 'label' in item;
|
||||||
|
}
|
||||||
|
const menuItems = rawMenuItems.filter(isMenuItem);
|
||||||
|
|
||||||
{/* Navigation Links */}
|
return (
|
||||||
<div className="hidden md:flex items-center space-x-8">
|
<>
|
||||||
|
{/* Top Bar */}
|
||||||
|
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
|
||||||
|
<Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link>
|
||||||
|
<div className="relative">
|
||||||
|
{loading ? null : session?.user ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
My Profile
|
||||||
|
{session.user.name && (
|
||||||
|
<span className="hidden sm:inline text-white font-medium text-sm">({session.user.name})</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-md shadow-lg ring-1 ring-black/10 dark:ring-white/10 focus:outline-none divide-y divide-neutral-100 dark:divide-neutral-700 animate-fade-in"
|
||||||
|
tabIndex={-1}
|
||||||
|
onBlur={() => setMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{session?.user && (
|
||||||
|
<div className="px-4 py-2 text-xs text-neutral-500 dark:text-neutral-400 border-b border-neutral-100 dark:border-neutral-700">
|
||||||
|
{session.user.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{menuItems.map((item, idx) =>
|
||||||
|
item.custom ? (
|
||||||
|
<div key={idx} className="px-4 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-neutral-700 dark:text-neutral-200">Theme</span>
|
||||||
|
{item.custom}
|
||||||
|
</div>
|
||||||
|
) : item.href ? (
|
||||||
|
<Link
|
||||||
|
key={idx}
|
||||||
|
href={item.href}
|
||||||
|
className={`block px-4 py-2 text-sm rounded transition-colors ${
|
||||||
|
item.active
|
||||||
|
? 'bg-primary/10 text-primary font-semibold'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
onClick={item.onClick}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded transition-colors"
|
||||||
|
onClick={item.onClick}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary font-semibold px-4 py-1"
|
||||||
|
onClick={() => signIn()}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subnav */}
|
||||||
|
<nav className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between items-center h-14">
|
||||||
|
{/* Left: Nav Links */}
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||||
pathname === item.href
|
pathname === item.href
|
||||||
? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300'
|
? 'text-primary font-semibold underline underline-offset-4'
|
||||||
: 'text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-700'
|
: 'text-neutral-700 dark:text-neutral-200 hover:text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -46,41 +157,14 @@ export default function Navbar() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Theme Switcher */}
|
{/* Right: Search */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<ThemeSwitcher />
|
<button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
|
||||||
|
<MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||||
{/* Mobile menu button */}
|
|
||||||
<button className="md:hidden p-2 rounded-md text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
|
||||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
|
</>
|
||||||
{/* Mobile menu */}
|
|
||||||
<div className="md:hidden">
|
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import Navbar from './Navbar';
|
||||||
|
|
||||||
|
export default function NavigationWrapper() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAccountPage = pathname?.startsWith('/account');
|
||||||
|
|
||||||
|
if (isAccountPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navbar />;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { Product } from '@/mock/product';
|
|||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
|
onAdd?: () => void;
|
||||||
|
added?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
|
function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
|
||||||
@@ -17,7 +19,7 @@ function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
|
|||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product }: ProductCardProps) {
|
export default function ProductCard({ product, onAdd, added }: ProductCardProps) {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const lowestPrice = Math.min(...product.offers.map(offer => offer.price));
|
const lowestPrice = Math.min(...product.offers.map(offer => offer.price));
|
||||||
|
|
||||||
@@ -86,7 +88,7 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
<div className="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-base-300">
|
<div className="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-base-300">
|
||||||
<figure className="relative">
|
<figure className="relative">
|
||||||
<img
|
<img
|
||||||
src={imageError ? 'https://placehold.co/300x200/6b7280/ffffff?text=No+Image' : product.image_url}
|
src={imageError ? '/window.svg' : product.image_url}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
className="w-full h-48 object-cover"
|
className="w-full h-48 object-cover"
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
@@ -116,6 +118,22 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
View Details
|
View Details
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
{onAdd && (
|
||||||
|
<button
|
||||||
|
className="btn btn-neutral btn-sm ml-2 flex items-center gap-1"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={added}
|
||||||
|
>
|
||||||
|
{added ? (
|
||||||
|
'Added!'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-lg leading-none">+</span>
|
||||||
|
<span className="text-xs font-normal">to build</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SessionProvider } from 'next-auth/react';
|
||||||
|
import { AuthProvider } from './AuthProvider';
|
||||||
|
import { ThemeProvider } from './ThemeProvider';
|
||||||
|
import NavigationWrapper from './NavigationWrapper';
|
||||||
|
|
||||||
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
|
||||||
|
<NavigationWrapper />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+533
-768
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { Session } from 'next-auth';
|
||||||
|
|
||||||
|
interface AuthStore {
|
||||||
|
session: Session | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
setSession: (session: Session | null) => void;
|
||||||
|
setLoading: (isLoading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthStore>((set) => ({
|
||||||
|
session: null,
|
||||||
|
isLoading: true,
|
||||||
|
setSession: (session) => set({ session }),
|
||||||
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { create, StateCreator } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface BuildPart {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image_url: string;
|
||||||
|
brand: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo?: string;
|
||||||
|
};
|
||||||
|
category: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
offers: Array<{
|
||||||
|
price: number;
|
||||||
|
url: string;
|
||||||
|
vendor: {
|
||||||
|
name: string;
|
||||||
|
logo?: string;
|
||||||
|
};
|
||||||
|
inStock?: boolean;
|
||||||
|
shipping?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildState {
|
||||||
|
selectedParts: Record<string, BuildPart | null>; // key: checklist component id
|
||||||
|
selectPartForComponent: (componentId: string, part: BuildPart) => void;
|
||||||
|
removePartForComponent: (componentId: string) => void;
|
||||||
|
clearBuild: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildStoreCreator: StateCreator<BuildState> = (set) => ({
|
||||||
|
selectedParts: {},
|
||||||
|
selectPartForComponent: (componentId, part) => set((state) => ({
|
||||||
|
selectedParts: { ...state.selectedParts, [componentId]: part },
|
||||||
|
})),
|
||||||
|
removePartForComponent: (componentId) => set((state) => {
|
||||||
|
const updated = { ...state.selectedParts };
|
||||||
|
delete updated[componentId];
|
||||||
|
return { selectedParts: updated };
|
||||||
|
}),
|
||||||
|
clearBuild: () => set({ selectedParts: {} }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useBuildStore = create<BuildState>()(
|
||||||
|
persist(buildStoreCreator, {
|
||||||
|
name: 'current-build-storage',
|
||||||
|
})
|
||||||
|
);
|
||||||
+18
-52
@@ -10,20 +10,6 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// Primary brand colors
|
|
||||||
primary: {
|
|
||||||
50: '#f0f9ff',
|
|
||||||
100: '#e0f2fe',
|
|
||||||
200: '#bae6fd',
|
|
||||||
300: '#7dd3fc',
|
|
||||||
400: '#38bdf8',
|
|
||||||
500: '#0ea5e9',
|
|
||||||
600: '#0284c7',
|
|
||||||
700: '#0369a1',
|
|
||||||
800: '#075985',
|
|
||||||
900: '#0c4a6e',
|
|
||||||
950: '#082f49',
|
|
||||||
},
|
|
||||||
// Secondary accent colors
|
// Secondary accent colors
|
||||||
accent: {
|
accent: {
|
||||||
50: '#fef2f2',
|
50: '#fef2f2',
|
||||||
@@ -105,50 +91,30 @@ module.exports = {
|
|||||||
daisyui: {
|
daisyui: {
|
||||||
themes: [
|
themes: [
|
||||||
{
|
{
|
||||||
light: {
|
pew: {
|
||||||
"primary": "#0ea5e9",
|
primary: '#4B6516', // Olive/army green
|
||||||
"primary-content": "#ffffff",
|
'primary-content': '#fff',
|
||||||
"secondary": "#ef4444",
|
accent: '#181C20', // Dark navy for CTA/footer
|
||||||
"secondary-content": "#ffffff",
|
'accent-content': '#fff',
|
||||||
"accent": "#f59e0b",
|
neutral: '#222',
|
||||||
"accent-content": "#ffffff",
|
'base-100': '#fff',
|
||||||
"neutral": "#737373",
|
'base-200': '#f5f6fa',
|
||||||
"neutral-content": "#ffffff",
|
'base-300': '#e5e7eb',
|
||||||
"base-100": "#ffffff",
|
info: '#3ABFF8',
|
||||||
"base-200": "#f5f5f5",
|
success: '#36D399',
|
||||||
"base-300": "#e5e5e5",
|
warning: '#FBBD23',
|
||||||
"base-content": "#171717",
|
error: '#F87272',
|
||||||
"info": "#0ea5e9",
|
|
||||||
"success": "#22c55e",
|
|
||||||
"warning": "#f59e0b",
|
|
||||||
"error": "#ef4444",
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
"primary": "#38bdf8",
|
|
||||||
"primary-content": "#ffffff",
|
|
||||||
"secondary": "#f87171",
|
|
||||||
"secondary-content": "#ffffff",
|
|
||||||
"accent": "#fbbf24",
|
|
||||||
"accent-content": "#ffffff",
|
|
||||||
"neutral": "#a3a3a3",
|
|
||||||
"neutral-content": "#ffffff",
|
|
||||||
"base-100": "#171717",
|
|
||||||
"base-200": "#262626",
|
|
||||||
"base-300": "#404040",
|
|
||||||
"base-content": "#ffffff",
|
|
||||||
"info": "#38bdf8",
|
|
||||||
"success": "#4ade80",
|
|
||||||
"warning": "#fbbf24",
|
|
||||||
"error": "#f87171",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'dark',
|
||||||
],
|
],
|
||||||
darkTheme: "dark",
|
darkTheme: "dark",
|
||||||
base: true,
|
base: true,
|
||||||
styled: true,
|
styled: true,
|
||||||
utils: true,
|
utils: true,
|
||||||
prefix: "",
|
logs: false,
|
||||||
logs: true,
|
rtl: false,
|
||||||
themeRoot: ":root",
|
prefix: '',
|
||||||
|
// 'pew' is the default theme
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user