Merge pull request 'user-auth' (#2) from user-auth into dev

Reviewed-on: https://gitea.gofwd.group/sean/gunbuilder-next-tailwind/pulls/2
This commit is contained in:
2025-06-30 06:37:16 -04:00
24 changed files with 2142 additions and 988 deletions

View File

@@ -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: '/**',
},
],
}, },
}; };

273
package-lock.json generated
View File

@@ -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
}
}
} }
} }
} }

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }

View File

@@ -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 });
}

View File

@@ -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,26 +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>
{/* Clear Build Modal */}
<Dialog open={showClearModal} onClose={() => setShowClearModal(false)} className="fixed z-50 inset-0 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4">
<div className="fixed inset-0 bg-black opacity-30" aria-hidden="true" />
<div className="relative bg-white rounded-lg max-w-sm w-full mx-auto p-6 z-10 shadow-xl">
<Dialog.Title className="text-lg font-bold mb-2">Clear Entire Build?</Dialog.Title>
<Dialog.Description className="mb-4 text-gray-600">
Are you sure you want to clear your entire build? This action cannot be undone.
</Dialog.Description>
<div className="flex justify-end gap-2">
<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>
</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 */} {/* Search and Filters */}
<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-3">
{/* Filters Row */} {/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 !bg-white"> <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 bg-white text-gray-900" 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}>
@@ -350,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 bg-white text-gray-900"> <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>
@@ -361,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 bg-white text-gray-900" 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>
@@ -376,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>
@@ -387,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
@@ -413,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>
@@ -429,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>
@@ -446,78 +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} &middot; {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={`/parts?category=${encodeURIComponent(component.category)}`} </div>
className="bg-[#4B6516] text-white py-1 px-3 rounded text-xs hover:bg-[#3a4e12] 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">
</div> {getProductCategoryForComponent(component.name)}
</td> </span>
</tr> </td>
))} <td className="px-6 py-4">
{selected ? (
<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>
); );
}) })
@@ -547,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>

View File

@@ -42,9 +42,6 @@
/* Button styles */ /* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */ /* Removed custom .btn-primary to avoid DaisyUI conflict */
.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 {

View File

@@ -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"] });
@@ -19,12 +18,9 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning data-theme="pew"> <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>
); );

View File

@@ -34,7 +34,7 @@ export default function LandingPage() {
</p> </p>
<div className="mt-10 flex items-top gap-x-6"> <div className="mt-10 flex items-top gap-x-6">
<Link <Link
href="/Builder" href="/build"
className="btn btn-primary text-base font-semibold px-6" className="btn btn-primary text-base font-semibold px-6"
> >
Get Building Get Building
@@ -46,7 +46,7 @@ export default function LandingPage() {
<img <img
alt="AR-15 Lower Receiver" alt="AR-15 Lower Receiver"
src="https://i.imgur.com/IK8FbaI.png" src="https://i.imgur.com/IK8FbaI.png"
className="max-w-md w-full h-auto object-contain rounded-xl shadow-lg" className="max-w-md w-full h-auto object-contain rounded-xl"
/> />
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react'; import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon } from '@heroicons/react/20/solid'; import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import SearchInput from '@/components/SearchInput'; import SearchInput from '@/components/SearchInput';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
import RestrictionAlert from '@/components/RestrictionAlert'; import RestrictionAlert from '@/components/RestrictionAlert';
@@ -12,6 +12,8 @@ import Link from 'next/link';
import { mockProducts } from '@/mock/product'; import { mockProducts } from '@/mock/product';
import type { Product } from '@/mock/product'; import type { Product } from '@/mock/product';
import Image from 'next/image'; import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
import { buildGroups } from '../build/page';
// Extract unique values for dropdowns // Extract unique values for dropdowns
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))]; const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
@@ -20,11 +22,11 @@ const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.
// Restrictions for filter dropdown // Restrictions for filter dropdown
const restrictionOptions = [ const restrictionOptions = [
'', 'All',
'NFA', 'NFA',
'SBR', 'SBR',
'SUPPRESSOR', 'Suppressor',
'STATE_RESTRICTIONS', 'State Restrictions',
]; ];
type SortField = 'name' | 'category' | 'price'; type SortField = 'name' | 'category' | 'price';
@@ -112,13 +114,13 @@ const Dropdown = ({
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label} {label}
</Listbox.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"> <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"> <span className="block truncate text-neutral-900 dark:text-white">
{value || placeholder} {value || placeholder}
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon <ChevronUpDownIcon
className="h-5 w-5 text-neutral-400" className="h-4 w-4 text-neutral-400"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
@@ -128,7 +130,7 @@ const Dropdown = ({
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" 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" 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> <Listbox.Options>
{options.map((option, optionIdx) => ( {options.map((option, optionIdx) => (
@@ -148,7 +150,7 @@ const Dropdown = ({
</span> </span>
{selected ? ( {selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400"> <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" /> <CheckIcon className="h-4 w-4" aria-hidden="true" />
</span> </span>
) : null} ) : null}
</> </>
@@ -163,8 +165,78 @@ const Dropdown = ({
); );
}; };
// 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() { export default function Home() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter();
const [selectedCategory, setSelectedCategory] = useState('All'); const [selectedCategory, setSelectedCategory] = useState('All');
const [selectedBrand, setSelectedBrand] = useState('All'); const [selectedBrand, setSelectedBrand] = useState('All');
const [selectedVendor, setSelectedVendor] = useState('All'); const [selectedVendor, setSelectedVendor] = useState('All');
@@ -174,6 +246,11 @@ export default function Home() {
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 [viewMode, setViewMode] = useState<'table' | 'cards'>('table'); 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 // Read category from URL parameter on page load
useEffect(() => { useEffect(() => {
@@ -198,8 +275,8 @@ export default function Home() {
if (selectedRestriction) { if (selectedRestriction) {
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa; if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr; else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
else if (selectedRestriction === 'SUPPRESSOR') matchesRestriction = !!part.restrictions?.suppressor; else if (selectedRestriction === 'Suppressor') matchesRestriction = !!part.restrictions?.suppressor;
else if (selectedRestriction === 'STATE_RESTRICTIONS') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0); else if (selectedRestriction === 'State Restrictions') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
else matchesRestriction = false; else matchesRestriction = false;
} }
@@ -280,11 +357,16 @@ export default function Home() {
const flags: string[] = []; const flags: string[] = [];
if (restrictions?.nfa) flags.push('NFA'); if (restrictions?.nfa) flags.push('NFA');
if (restrictions?.sbr) flags.push('SBR'); if (restrictions?.sbr) flags.push('SBR');
if (restrictions?.suppressor) flags.push('SUPPRESSOR'); if (restrictions?.suppressor) flags.push('Suppressor');
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('STATE_RESTRICTIONS'); if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('State Restrictions');
return flags; return flags;
}; };
const handleAdd = (part: Product) => {
setAddedPartIds((prev) => [...prev, part.id]);
setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500);
};
return ( return (
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900"> <main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
{/* Page Title */} {/* Page Title */}
@@ -309,56 +391,86 @@ export default function Home() {
{/* Search and Filters */} {/* Search and Filters */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700"> <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"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
{/* Search Row */} {/* Search Row */}
<div className="mb-4 flex justify-end"> <div className="mb-3 flex justify-end">
<div className="w-1/2"> <div className={`transition-all duration-300 ease-in-out flex justify-end ${isSearchExpanded ? 'w-1/2' : 'w-auto'}`}>
<SearchInput {isSearchExpanded ? (
label="Search" <div className="flex items-center gap-2 w-full justify-end">
value={searchTerm} <div className="flex-1 max-w-md">
onChange={setSearchTerm} <SearchInput
placeholder="Search parts..." 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>
</div> </div>
{/* Filters Row */} {/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-6 gap-4"> <div className="grid grid-cols-2 md:grid-cols-6 lg:grid-cols-7 gap-3">
{/* Category Dropdown */} {/* Category Dropdown */}
<Dropdown <div className="col-span-1">
label="Category" <Dropdown
value={selectedCategory} label="Category"
onChange={setSelectedCategory} value={selectedCategory}
options={categories} onChange={setSelectedCategory}
placeholder="All categories" options={categories}
/> placeholder="All categories"
/>
</div>
{/* Brand Dropdown */} {/* Brand Dropdown */}
<Dropdown <div className="col-span-1">
label="Brand" <Dropdown
value={selectedBrand} label="Brand"
onChange={setSelectedBrand} value={selectedBrand}
options={brands} onChange={setSelectedBrand}
placeholder="All brands" options={brands}
/> placeholder="All brands"
/>
</div>
{/* Vendor Dropdown */} {/* Vendor Dropdown */}
<Dropdown <div className="col-span-1">
label="Vendor" <Dropdown
value={selectedVendor} label="Vendor"
onChange={setSelectedVendor} value={selectedVendor}
options={vendors} onChange={setSelectedVendor}
placeholder="All vendors" options={vendors}
/> placeholder="All vendors"
/>
</div>
{/* Price Range */} {/* Price Range */}
<div className="relative"> <div className="col-span-1">
<Listbox value={priceRange} onChange={setPriceRange}> <Listbox value={priceRange} onChange={setPriceRange}>
<div className="relative"> <div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Price Range Price Range
</Listbox.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"> <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"> <span className="block truncate text-neutral-900 dark:text-white">
{priceRange === '' ? 'Select price range' : {priceRange === '' ? 'Select price range' :
priceRange === 'under-100' ? 'Under $100' : priceRange === 'under-100' ? 'Under $100' :
@@ -368,7 +480,7 @@ export default function Home() {
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon <ChevronUpDownIcon
className="h-5 w-5 text-neutral-400" className="h-4 w-4 text-neutral-400"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
@@ -378,7 +490,7 @@ export default function Home() {
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" 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" 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> <Listbox.Options>
{[ {[
@@ -404,7 +516,7 @@ export default function Home() {
</span> </span>
{selected ? ( {selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400"> <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" /> <CheckIcon className="h-4 w-4" aria-hidden="true" />
</span> </span>
) : null} ) : null}
</> </>
@@ -418,26 +530,28 @@ export default function Home() {
</div> </div>
{/* Restriction Filter */} {/* Restriction Filter */}
<Dropdown <div className="col-span-1">
label="Restriction" <Dropdown
value={selectedRestriction} label="Restriction"
onChange={setSelectedRestriction} value={selectedRestriction}
options={restrictionOptions} onChange={setSelectedRestriction}
placeholder="All restrictions" options={restrictionOptions}
/> placeholder="All restrictions"
/>
</div>
{/* Clear Filters */} {/* Clear Filters */}
<div className="flex items-end"> <div className="col-span-1 flex items-end">
<button <button
onClick={clearFilters} onClick={clearFilters}
disabled={!hasActiveFilters} disabled={!hasActiveFilters}
className={`w-full px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2 ${ className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${
hasActiveFilters hasActiveFilters
? 'bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white' ? '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' : 'bg-neutral-200 dark:bg-neutral-700 text-neutral-400 dark:text-neutral-500 cursor-not-allowed'
}`} }`}
> >
<XMarkIcon className="h-4 w-4" /> <XMarkIcon className="h-3.5 w-3.5" />
Clear All Clear All
</button> </button>
</div> </div>
@@ -480,24 +594,12 @@ export default function Home() {
</div> </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 */} {/* Table View */}
{viewMode === 'table' && ( {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="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"> <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"> <table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead className="bg-neutral-50 dark:bg-neutral-700"> <thead className="bg-neutral-50 dark:bg-neutral-700 sticky top-0 z-10 shadow-sm">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Product Product
@@ -527,7 +629,9 @@ export default function Home() {
<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" /> <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>
<div> <div>
<div className="text-sm font-semibold text-neutral-900 dark:text-white">{part.name}</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 className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</div>
</div> </div>
</td> </td>
@@ -542,11 +646,51 @@ export default function Home() {
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <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"> // Find if this part is already selected for any component
Add const selectedComponentId = Object.entries(selectedParts).find(([_, selectedPart]) => selectedPart?.id === part.id)?.[0];
</a>
</Link> // 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> </td>
</tr> </tr>
))} ))}
@@ -577,7 +721,7 @@ export default function Home() {
{viewMode === 'cards' && ( {viewMode === 'cards' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{sortedParts.map((part) => ( {sortedParts.map((part) => (
<ProductCard key={part.id} product={part} /> <ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
))} ))}
</div> </div>
)} )}

View File

@@ -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>

View File

@@ -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}</>;
}

View File

@@ -3,10 +3,23 @@
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, UserCircleIcon } from '@heroicons/react/24/outline'; 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: '/parts', label: 'Parts Catalog' }, { href: '/parts', label: 'Parts Catalog' },
@@ -14,12 +27,114 @@ export default function Navbar() {
{ href: '/my-builds', label: 'My Builds' }, { href: '/my-builds', label: 'My Builds' },
]; ];
// Dropdown menu items
const rawMenuItems = [
session?.user
? {
label: 'Profile',
href: '/account/profile',
active: pathname === '/account/profile',
onClick: () => setMenuOpen(false),
}
: undefined,
session?.user
? {
label: 'Sign Out',
onClick: () => {
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);
return ( return (
<> <>
{/* Top Bar */} {/* Top Bar */}
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8"> <div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
<span className="font-bold text-lg tracking-tight">Pew Builder</span> <Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link>
<UserCircleIcon className="h-7 w-7 text-white opacity-80" /> <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> </div>
{/* Subnav */} {/* Subnav */}
@@ -42,15 +157,11 @@ export default function Navbar() {
))} ))}
</div> </div>
{/* Right: Sign In + Search */} {/* Right: Search */}
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Link href="/signin" className="btn btn-sm btn-ghost text-sm font-medium">
Sign In
</Link>
<button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"> <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" /> <MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
</button> </button>
<ThemeSwitcher />
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -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 />;
}

View File

@@ -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>

View File

@@ -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>
);
}

File diff suppressed because it is too large Load Diff

16
src/store/useAuthStore.ts Normal file
View File

@@ -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 }),
}));

View File

@@ -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',
})
);